Skip to content
oleg-tkachukPublic

About

Shared Task (taskfile.dev) modules — release chain, language gates and Kubernetes helpers, included over git instead of copied per repository.

Topics

Resources

Code of conduct

Contributing

Security policy

Stars

0 stars

Watchers

0 watching

Forks

Latest commit

 

History

367 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

taskfiles

release ci Task 3.54+ license: MIT

Reusable Task modules that repositories include over git, pinned to a tag: one toolkit to build, test, run CI and release, instead of per-repo Makefiles and scripts.

  • One release chain: git-derived SemVer, idempotent image and Helm chart publishing, cosign signing with SBOM.
  • Safe by construction: quoted shell inputs, lint gates for scoping and parity, behaviour tests.
  • Public API: modules released automatically under strict SemVer.

Requires Task 3.54+. Remote Taskfiles are stable from 3.53, with no experiment flag to set; 3.54 is the floor because earlier versions clone git includes into a shared temporary directory, where another local user can plant a Taskfile that runs in place of a module (GHSA-679p-658w-m3wr, fixed in 3.54.0).

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. oleg-tkachuk/paladin is another, split into three entry points by who runs the command.

Using it

version: "3"
silent: true

vars:
  TASKLIB: 'https://github.com/oleg-tkachuk/taskfiles.git//%s?ref=v11.2.1'
  PROJECT_NAME: '{{.PROJECT_NAME | default "orders-api"}}'
  IMAGE_NAMESPACE: '{{.IMAGE_NAMESPACE | default "acme"}}'
  K8S_NAMESPACE: '{{.K8S_NAMESPACE | default "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.

In a monorepo, module inputs belong in the include's own vars: rather than at the top level shown here — see Variable scoping.

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 removal is a major bump — these modules are a public API, and ?ref= is the only thing between a removal here and your Taskfile. A rename is not: the old task name stays as an alias, and an old input name is still read, for one major, so moving to the release that renames something costs nothing until the next one. Adding a task or an input is a minor; a fix is a patch. The policy, and how a release is cut, is in RELEASE.md.

Working on the library itself

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 lint is the whole gate, and it includes the behaviour tests in tests/<module>.test.sh: each runs a module's tasks against shims for the tools it calls and asserts on the exit status, the output and the argv the shims saw. A fix to a module comes with a case there that fails without it.

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.

Workflows are audited

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 Renovate moves the pins — a tag is mutable, and a pin nobody updates is its own problem.

A step longer than one command runs a TypeScript command from .github/ci/ rather than carrying it in the YAML, so it is type-checked, unit-tested and can be run on a laptop. Node runs the files as they are; the setup-ci action pins Node and installs the locked dependencies. task lint refuses a multi-line run: and a command no step runs.

How a module is written

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.

Modules

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
github/ github force-push a protected branch without leaving protection off
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

Local clusters

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.

Conventions

Variable scoping

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.

A few derived values cross a module boundary on purpose: runtime/* and cosign read _REL_VERSION and _REL_IMAGE from release, and cosign reads _REL_DIGEST_FILE — where image:push records the digest it pushed — 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.

That is a contract between modules, so it is written down: release names the values on an # exports: line in its header, and task lint refuses a read of another module's var that is not exported there.

That namespace is the repository's, not the component's. A monorepo whose root Taskfile includes several component Taskfiles puts all of their top-level vars: into one pool: a name one component declares is what every other component's includes read, and a module's own default never applies there.

a (declares DOCKERFILE: ./deploy/Dockerfile)  -> ./deploy/Dockerfile
b (declares nothing)                          -> ./deploy/Dockerfile  (not ./Dockerfile)
c (includes a different module entirely)      -> ./deploy/Dockerfile

Measured on Task 3.53.1 and again on 3.54.0. Worse, with two components declaring different values and a third declaring none, which one wins is not stable: repeated runs of one unchanged task return both answers.

So in a monorepo a module input goes in the vars: of the include that reads it, where it is scoped to that component:

includes:
  release:
    taskfile: '{{printf .TASKLIB "release"}}'
    dir: .
    vars:
      DOCKERFILE: ./deploy/Dockerfile

The quickstart above puts inputs at the top level because it shows one repository with one component — a pool of one, which cannot collide. A name every component declares is safe there too, because the pool then holds one value per component. A name only some declare is the shape that bites.

When a module's values are decided

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: orders } }

# does not — the module derived its namespace when it loaded
- task: k8s:restart
  vars: { K8S_NAMESPACE: orders }

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.

Task naming

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 task you don't need

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 exist

Real 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

An empty value turns nothing off

{{.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   # → none

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

Include naming

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.

Logging

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.

Versioning

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>.dirty.<now>.g<sha>

<base> is the latest version tag one patch up, so a build after a release sorts above it; VERSION_BASE sets it by hand. After a pre-release tag X.Y.Z-rc.N a build is X.Y.Z-rc.N.dev.… instead, which sorts below the release the candidate is for.

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.

Every tool invocation takes flags

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.

Commit the remote lock

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=v11.2.1" 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. Ignore what is inside it instead, then make the exception — git re-includes nothing under a directory that is itself excluded, so .task/ followed by !.task/remote/ still ignores the lock:

.task/*
!.task/remote/

release:doctor warns when the lock is ignored or not committed.

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.

Idempotency

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.

License

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.

About

Shared Task (taskfile.dev) modules — release chain, language gates and Kubernetes helpers, included over git instead of copied per repository.

Topics

Resources

Code of conduct

Contributing

Security policy

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Used by

Contributors

Languages