Skip to content

Pipeline templates

When the same sequence of steps shows up in a third project, copying it a third time stops being reasonable. A template is a named block of steps with parameters:

steps:
- use: techlite/imagem@^1.0.0
id: web
with:
registry: us-central1-docker.pkg.dev/acme/apps
nome: acme-web
versao: ${outputs['versao'].version}
credential: ../service-account.json

A template is writing sugar. It disappears before anything looks at the pipeline: by the time the resolver, the cache, the consent gate, oren.lock and the CI generator see your file, there is no use: anywhere — only the steps you would have typed by hand.

This is the whole design decision, and the alternative was tempting: a composite task, one step that runs several inside it. That would have been less code here and much worse everywhere else, because a composition lives at run time and erases exactly what this project exists to make visible:

  • Consent would become all-or-nothing. Approving “build and publish” is not the same as approving a build, then approving a push that carries a credential.
  • Cache would become all-or-nothing. A block containing one effects: external step can never be served from cache, so a pure build inside it would stop being cached forever.
  • effects would collapse to the worst case among the steps.
  • A failure would report “the composition failed” instead of naming the push that did not go up.

Expanded, none of that happens. The abstraction lives only where it is written.

apiVersion: oren.sh/v1
kind: PipelineTemplate
metadata:
namespace: techlite
name: imagem
version: 1.0.0
spec:
parameters:
required: [registry, nome, versao, credential]
properties:
registry: { type: string }
nome: { type: string }
versao: { type: string }
credential: { type: string }
context: { type: string, default: "." }
buildArgs: { type: object }
outputs:
image: ${outputs['push'].image}
digest: ${outputs['push'].digest}
steps:
- id: build
task: techlite/build-docker-image@^1.0.0
implementation: techlite/build-docker-image-kaniko
inputs:
image: ${parameters.registry}/${parameters.nome}:${parameters.versao}
context: ${parameters.context}
archiveName: ${parameters.nome}.tar
buildArgs: ${parameters.buildArgs}
dependencies:
source: "."
artifacts: ./.oren/artefatos
- id: push
task: techlite/push-docker-image@^1.0.0
implementation: techlite/push-docker-image-skopeo
inputs:
image: ${outputs['build'].image}
dependencies:
artifacts: ./.oren/artefatos
registryCredential: ${parameters.credential}

It goes in the registry as *.template.yaml, next to the contracts — and is resolved by range, @^1.0.0, like a task.

The same rigour as a task’s inputs: an unknown parameter is an error, because a registy: typed wrong would otherwise vanish in silence; a missing required one is an error; and default: is applied here.

An optional parameter that was not supplied disappears from the key. buildArgs: ${parameters.buildArgs} with no with.buildArgs produces a step with no buildArgs at all — not one with an empty value.

Alone in a string, ${parameters.x} yields the value, keeping its type: a map stays a map. In the middle of text, it concatenates.

Only parameters. is substituted at expansion time. outputs, env and properties are resolved during execution, with values that do not exist yet — so a template both receives and produces expressions:

with:
versao: ${outputs['versao'].version} # goes in as text, resolved at run time

spec.outputs names what the template produces, as an alias for a field of one of its steps:

outputs:
image: ${outputs['push'].image}
digest: ${outputs['push'].digest}

Whoever uses it then references the id they wrote themselves:

- use: techlite/imagem@^1.1.0
id: web
with: { ... }
- id: deploy
inputs:
images:
app: ${outputs['web'].image} # not web-push

Without this, every internal step id is the template’s public API: renaming push to publish inside it would break every pipeline that referenced web-push, turning a cosmetic rename into a major version.

An alias, and never a computed value — ${outputs['push'].registry}/... is refused. Computing is a step’s job, and a composed value could not be spliced into the middle of a consumer’s text without producing nested ${}.

Four refusals worth knowing:

situation what happens
outputs['web'].unknown error, listing the outputs that exist
a real step with id: web refused — outputs['web'] would have two owners
an output pointing outside the template refused, listing its steps
an output that is not an alias refused, stating the expected form

Referencing ${outputs['web-push'].image} directly still works. Nothing is hidden — the declared output is the stable surface, not the only one. If you need the build’s archive, which the template does not declare, the generated id is there.

With id: web, the template’s build and push become web-build and web-push. References between the template’s own steps are rewritten along with them.

That rewrite is not cosmetic. Without it, two uses of the same template would produce two pushes both pointing at the first build — resolving with no error, because the id would still exist.

Without an id, the template’s step ids are kept as they are. That is the right choice when a template is used once and the rest of the pipeline references its output by name:

- use: techlite/versionar@^1.0.0 # generates `analise` and `versao`
with: { manifest: package.json }
- id: tag
task: techlite/commit-and-tag@^2.0.0
implementation: techlite/commit-and-tag-alpine
inputs:
tagName: v${outputs['versao'].version}

oren validate shows every generated step, with its generated id. A template hides nothing:

[3/7] Constrói acme-web · web-build · build-docker-image
[4/7] Publica acme-web · web-push · push-docker-image

Not step by step. Whoever switches off “publish the image” does not want the push going up on its own.

Until it is published, every project that uses the template needs a copy of the file — which is the opposite of what a template exists for. Ten projects would mean ten copies, and fixing one bug would mean ten commits.

Terminal window
oren publish ./imagem.template.yaml

A single file, and not a directory: a template has no contract-and-implementation pair beside it. What decides it is the kind inside the document, never the file name — the name is a convention for scanning a directory, and nothing more.

The portal refuses a template whose referenced tasks do not exist there. Same rule as an implementation, and for the same reason: whoever consumed it would receive a use: that does not expand, and would only find out on the first run.

After that, oren install downloads it like a task — and before expanding, because expansion is what consumes it.

A template has almost none of its own visible surface. The portal follows it:

  • Outputs get their type from the contract of the step that produces them, resolved now. The template holds only the alias.
  • Dependencies are the union of what its steps require, showing the strongest of each: in techlite/imagem, artifacts is written by the build and only read by the push, so it appears as write — that is what you are granting.
  • Effects stay per step. There is no aggregate. A template with a build (none) and a push (external) shows both, because collapsing them would say “this acts beyond its outputs” — true of the set, false of the build, and it would leave the reader thinking nothing there is cacheable.

Because the reference is a range, @^1.0.0 resolves to whatever is published at the moment you look. Publishing push@1.1.0 after the template changes what the catalogue shows — deliberately: that is the version that will run.

Use another template. Depth is one level, for the same reason extends is: nesting gives back the opacity that expansion exists to avoid.

Iterate. There is no “generate N builds from a list”. Two images means two use: blocks — which is four lines, and keeps every step countable in the file.

Choose an implementation for you. implementation: is required inside the template just as it is in a step: it is a decision, and a template makes it once, on the author’s behalf, in writing.