Shared Task modules — one implementation of the release chain, the language gates and the Kubernetes conveniences, included over git instead of copied into every repository.
Requires Task 3.53+ — remote Taskfiles are stable from that version, with no experiment flag to set.
See demo/ for a working example — one small Go service
taken from go test to a signed, running deployment using six of these
modules, walkthrough included.
For a real consumer rather than a constructed one, see
oleg-tkachuk/hetzner-iac —
a Talos/Pulumi platform on Hetzner Cloud. It includes four of these modules,
trims three with excludes:, says why k8s is left out, and resolves the
hcloud selector and token by running a program, because neither can be a
constant. Pinned to a commit, so it cannot drift from what is said here.
version: "3"
silent: true
vars:
TASKLIB: 'https://github.com/oleg-tkachuk/taskfiles.git//%s?ref=v7.1.2'
PROJECT_NAME: billing-api
IMAGE_NAMESPACE: acme
K8S_NAMESPACE: acme
includes:
release: { taskfile: '{{printf .TASKLIB "release"}}', dir: . }
k8s: { taskfile: '{{printf .TASKLIB "k8s"}}', dir: . }
go: { taskfile: '{{printf .TASKLIB "go"}}', dir: . }
tasks:
deploy: { cmds: [{ task: release:deploy }] }
restart: { cmds: [{ task: k8s:restart }] }
test: { cmds: [{ task: go:test }] }dir: . is required on every include — it pins the module's commands to the
including component's directory.
The three names above includes: are inputs, not decoration. A module never
declares a bare input name — a var declared inside an included file shadows
the including file's value of the same name — so it reads
{{.PROJECT_NAME | default "service"}} instead, and the consumer sets the value
once at file level where every include sees it. Drop them and the image
becomes localhost:5000/service and k8s:restart rolls something in
default. See Variable scoping.
Pin a tag. Not a branch, or your build changes when someone else commits —
and never a commit SHA: Task clones with --depth 1, and git refuses a bare
SHA with that flag. The tag above is an example; the current one, and what
moving to it costs, is on the
releases page.
A rename or a removal is a major bump — these modules are a public API, and
?ref= is the only thing between a rename here and your Taskfile. Adding a
task or an input is a minor; a fix is a patch. How a release is cut is in
RELEASE.md.
Point TASKLIB at a checkout beside your repository. The %s stays; only
what surrounds it changes, so the include lines are identical either way and an
edit to the library is visible without cutting a tag:
vars:
TASKLIB: '../taskfiles/%s'TASKLIB holds the whole path with a %s where the module goes, and each
include fills it in with printf — Task's own template function, evaluated
while the include graph is built, not a shell call. One variable rather than a
base and a suffix, and the version written once.
It has to work this way because an include path substitutes plain {{.VAR}}
references and evaluates nothing else there: no {{if}}, and no variable whose
value is itself a template. So TASKLIB: '...//%s?ref={{.V | default "v2.2.0"}}'
does not resolve — it reaches git as a literal ref and fails. The version is a
constant in the file, which is what pinning means anyway.
task surface prints every module's tasks from this checkout, which is the one
view a consumer cannot get: task --list-all there shows what that repository
included, not what the library offers.
Only the YAML is fetched. Sibling scripts in this repo are not downloaded, which is why every module is self-contained and expresses its logic in Task's own primitives rather than shelling out to a helper.
zizmor runs over .github/workflows/ in CI and in
the pre-commit hook, in its auditor persona. Actions are pinned to commit
SHAs with the version in a trailing comment, and Dependabot moves the pins —
a tag is mutable, and a pin nobody updates is its own problem.
Guards are preconditions (with an explanatory msg),
skips are status, cleanup is defer, iteration is for, and required
inputs are requires. Multi-line bash blocks were the previous
implementation; they are not the current one.
| Module | Namespace | What it covers |
|---|---|---|
argocd/ |
argocd |
hard-refresh the apps a deploy republished, and operate the Argo server |
auth/ |
auth |
mint a local-dev JWT |
checkov/ |
checkov |
policy-scan manifests, charts, Dockerfiles and workflows |
codegen/ |
codegen |
mocks, protobuf stubs, go generate, sqlc — and the drift gates for them |
compose/ |
compose |
local stack up/down/reset/logs |
cosign/ |
cosign |
sign the published image and chart, attest an SBOM, verify both |
go/ |
go |
build, test (+coverage, +integration, +tagged-compile), lint, fmt, tidy, vuln, bench, dep bumps |
hcloud/ |
hcloud |
what a project owns in Hetzner Cloud, the power state of its servers, restarts at three levels of force, SSH and a VNC console |
helm/ |
helm |
what is installed on a cluster, and removing it |
k8s/ |
k8s |
restart, logs, status, helm upgrade --install, port-forward |
monorepo/ |
monorepo |
run one target across every component, registry preflight |
node/ |
node |
install, dev, build, lint, test, e2e, verify, generate, dep bumps |
pnpm/ |
pnpm |
keep the corepack pnpm pin current and agreed across a repo's apps |
python/* — poetry, uv |
python |
install/test/lint/format/typecheck/lock and dep bumps, one surface per manager |
release/ |
release |
version derivation, image build/push, chart lint/render/package/push, deploy |
runtime/* — docker, orbstack, minikube, kind, k3d |
local |
run a locally built image on a local cluster, no registry |
sealed-secrets/ |
sealed-secrets |
seal a value into a committable SealedSecret, fetch the controller key |
security/ |
security |
govulncheck, golangci-lint, gitleaks, trivy, buf breaking |
release:image:build writes to whatever Docker daemon the host talks to. Whether
that image is then visible to your cluster depends on the cluster: OrbStack and
Docker Desktop share the host's store and need nothing, minikube and kind and
k3d each have their own and need an import step. That difference is the only
thing the runtime/* modules contain, and they all expose the same three
tasks — check, image:load, install — so moving between them is one line:
includes:
local: { taskfile: '{{printf .TASKLIB "runtime/orbstack"}}', dir: . }Include one alongside release, not instead of it: local:image:load moves
what release:image:build produced, and local:install helm-installs the
chart from disk against it with pullPolicy=IfNotPresent.
Each module is a directory holding a Taskfile.yaml and a README.md — the
table above links to them, and each one documents that module's inputs, its
tasks and a worked example. An include names the directory, so the layout inside
a module stays the module's own business.
release and k8s are two modules rather than one for the same reason a
publish-only CI job has no kubeconfig: neither half needs the other. Include
whichever the component actually does.
That split has to happen at your include, not inside a module. A module that
includes its own parts hands those parts the library's working directory
rather than yours — only the include you write carries dir: . — so every
relative path in them (CHART_DIR, DOCKERFILE, the info file) resolves in
the wrong tree. Each module's header documents its inputs. Run task --list-all in a consumer
to see the full surface.
A var declared in an included file shadows the
including file's value of the same name, and all included files share one
namespace. So no module here declares a bare knob name: every input is read
inline as {{.NAME | default ...}}, and every derived value carries its module's
prefix — _REL_IMAGE, _GO_PKG, _CS_KEY, _RT_CTX. Consumers own the plain
names, and two modules can never mean different things by the same one.
Two derived values cross a module boundary on purpose: runtime/* and cosign
read _REL_VERSION and _REL_IMAGE from release, because they act on what a
release published. They read them — they must never declare them, or they would
shadow the very values they are supposed to act on.
A module's vars: are evaluated once,
as the include loads — not per call. Values reach them from the including
file's own vars, from the include's vars: block, and from the command line. A
vars: block on a task: reference does not reach them: it is visible to a
task's own vars: and nowhere else.
# configures the module — the include carries the value
k8s: { taskfile: '{{printf .TASKLIB "k8s"}}', dir: ., vars: { K8S_NAMESPACE: billing } }
# does not — the module derived its namespace when it loaded
- task: k8s:restart
vars: { K8S_NAMESPACE: billing }That is why every module here is scoped to one component. A repository that needs the same module aimed at several targets includes it once per target, rather than passing the target at the call.
The X: '{{.X | default "..."}}' form in the quickstart is not decoration: a var
that reads a different name is fixed at whatever that name held, while one
that reads its own sees a value given on the command line. Write inputs that
way in a consumer and task deploy REGISTRY=other works.
Public tasks are the plain verb for the work (build, test,
deploy, restart) namespaced by area when a module has several (chart:push,
image:build, deps:update).
A module's do-everything task is a verb too, not all — security:scan,
cosign:sign, codegen:regenerate. all names how much rather than what, in
the one place a consumer looks first, and it reads as a quantifier beside
siblings that are actions. Where the verb is taken by one of the steps, the
composite takes the area instead: codegen:regenerate sits beside the
codegen:generate step it runs.
Nor is a composite a selector. scan select=<tool> would need an enumerable
set of interchangeable items, and in these modules the items ARE the tasks:
each carries its own description and its own precondition naming the tool it
needs, and a selector hides both from --list. Variants are nested names here
— test:coverage, fmt:check, deps:update — and one convention for that is
the point. Internal helpers are internal: true and named
_verb when they stand alone, or parent:_variant when they are one branch of
a public task — the complementary image:build:_do / image:build:_skipped
pair, for instance, where exactly one of the two runs.
A module's task list is not all-or-nothing. Task's own excludes: on the
include drops specific tasks from it — not hidden from --list-all,
genuinely gone:
includes:
k8s:
taskfile: '{{printf .TASKLIB "k8s"}}'
dir: .
excludes: ["port-forward"]$ task k8s:port-forward
task: Task "k8s:port-forward" does not existReal case: an infrastructure repository that only ever runs k8s:restart
and k8s:status has no service behind a Deployment to reach through
port-forward, and likely no use for tailing logs either — exclude both:
excludes: ["port-forward", "logs"]Name the task the way the module itself does — port-forward, not
k8s:port-forward. The namespaced form matches nothing, and excludes
fails silently: the task stays in the list, with no error saying why the
exclusion did not take.
Check what the task you are dropping is built on, too. monorepo:deploy is
defined in terms of each, so excluding each takes deploy down with it —
and the error names each, which is not the task anybody asked for:
$ task ship:deploy
task: Task "ship:each" does not exist{{.X | default "d"}} yields d when X
is unset and when it is empty, so a consumer cannot switch a default off by
passing nothing:
$ task show # X unset → fallback
$ task show X= # X empty → fallback, not ""
$ task show X=none # → noneEvery opt-out here is therefore an explicit value a consumer has to name —
COSIGN_SIGN=0 makes the signing tasks no-ops, E2E_SETUP=none skips the
browser download. When adding one, pick a value and check for it; do not write
a default that an empty string is supposed to defeat, because it will not.
A module is included under its own directory name, because the include alias is what names every task it brings in.
A family is the exception, and it is the point of being one. runtime/*
and python/* hold variants of a single surface — five local clusters, two
Python package managers — so they are included under the family name, local
and python, and swapping one variant for another is a single line with no
task rename anywhere in the consumer. task lint refuses a family whose
variants stop exposing the same tasks, which is what keeps that promise true;
a deliberate difference goes in SURFACE_EXCEPT with a reason.
Component-scoped modules print <marker> <component> · <area> · <what happened>; repo-scoped ones (security, monorepo, argocd) print
<marker> <module> · <what happened>, because there is no one component to
name.
The marker vocabulary:
| Marker | Means | Colour |
|---|---|---|
◉ |
starting work | cyan |
✔ |
done | green |
○ |
nothing to do — skipped on purpose | grey |
▲ |
worth reading, but not fatal | yellow |
✖ |
failed | red |
The shape carries the meaning and the colour only reinforces it, so a line
still reads in a log that has been stripped of ANSI. Each marker is a single
codepoint — no variation selector — so every line starts the same width in
every terminal. Set NO_COLOR to any value to get the glyphs without the
escapes.
· separates the fields of a line and → marks a result, both from the same
vocabulary.
One scheme, in release/:
exact tag, clean tree → X.Y.Z
otherwise, clean tree → <base>-dev.<committer-ts>.g<sha>
dirty tree → <base>-dev.<committer-ts>.g<sha>.dirty.<now>
Committer timestamp rather than wall clock, so a clean tree produces the same
version on every run — that is what makes the publish path idempotent.
Timestamp before sha, so SemVer's left-to-right pre-release comparison orders
builds chronologically and ArgoCD's >=0.0.0-0 resolver always picks the
newest push.
A module wraps a tool; it does not own it. So every external command here
accepts a verbatim pass-through, named after the tool and ending in _FLAGS:
sec:
taskfile: '{{printf .TASKLIB "security"}}'
dir: .
vars: { TRIVY_FLAGS: '--severity HIGH,CRITICAL', GITLEAKS_FLAGS: '--log-opts=-n50' }The value is spliced into the command unquoted, which is what makes several flags in one string work — and what makes it the wrong place for a value that came from outside the repository. These are for the person writing the Taskfile, not for user input.
Where one module runs the same tool several ways, each call gets its own input
rather than one shared bag: HELM_LINT_FLAGS, HELM_TEMPLATE_FLAGS,
HELM_PACKAGE_FLAGS and HELM_FLAGS (registry calls) in release. A single
HELM_FLAGS would put --dry-run on a helm package that has no such flag.
A module pulled over the network — ?ref=vX.Y.Z rather than a checkout beside
you — leaves a lock in .task/remote/:
.task/remote/git.github.com.go.<hash>.checksum
Task refuses to run when the content behind that ref no longer matches it:
task: Taskfile "...//go?ref=v7.1.2" not trusted by user
That is the only thing standing between a moved tag and your build, so commit
those .checksum files. Most repositories ignore .task/, which throws the
lock away — add an exception:
.task/
!.task/remote/Refresh it deliberately when you bump the pin, with task --download.
A checkout-based include produces no lock, because there is no fetch to verify — nothing to commit until you move to a tag.
Re-running task deploy on an unchanged commit does nothing:
the image build is skipped when a clean tree's image is already in the local
store, and the chart push is skipped when that version is already in the
registry. A dirty tree always rebuilds — it is not reproducible by definition.
Everything runs under silent: true, so what you see is these lines plus
whatever the underlying tool prints.
MIT — use it, copy a module into your own repository, change it, ship it in something commercial. The one condition is that a copy carries the copyright line and the permission notice with it.
Changing this library? See CONTRIBUTING.md. Found a vulnerability? See SECURITY.md rather than a public issue.