A copier template that writes a complete CI/CD pipeline into a repository. Rendering it gives a
repo an orchestrator, seven local reusable workflow stages, a composite setup action, the gate
scripts those stages run, and every config file the tools read — all committed in that repo's own
tree. Nothing is fetched at run time, so a run can never be changed by anything outside the commit
it ran on, and copier update is what moves a repo forward.
The pipeline is a template, not a dependency. There is no shared workflow repo to call, no
moving @v1 tag, and no cross-repository reference anywhere in what gets rendered — every stage is
reached as uses: ./.github/workflows/ci-<stage>.yml.
.github/
workflows/ci.yaml the orchestrator — the only file with an `on:` block
workflows/ci-*.yml the stages, called locally by ci.yaml
actions/setup/ the composite action that installs every toolchain
release-please-config.json + manifest
renovate.json
scripts/ the gate scripts the stages run
.taskfiles/ + taskfile.yml local task runner; `task smoke` is the one thing CI calls
.yamllint.yml .golangci.yml .hadolint.yaml ruff.toml eslint.config.mjs .node-version
.dockerignore .gitignore
.copier-answers.yml the answers, so `copier update` knows what to re-render
Exactly which of those appear depends on the variant. Six fully rendered repos are
committed under examples/ — that is the fastest way to see what you get.
$ uvx copier copy gh:jacaudi/template .
$ git add -A && git commit -m "ci: adopt jacaudi/template"Copier asks the questions in Variants and writes the tree above. Then, in order:
- Point the branch ruleset at exactly
ci— before swapping workflows in, never after. - Set
APP_IDandAPP_PRIVATE_KEYas repo or org secrets, under exactly those names. See The App secrets. - Seed
.github/release-please-manifest.jsonfromgit describe --tags --abbrev=0, so release-please does not propose0.1.0for a repo already atv3.4.1. - Fill in
task smoke. The stock body assumes an HTTP service on:8080; what "behaves" means is repo knowledge and CI cannot guess it.
Step 1 is the one with a trap in it. Until the ruleset names ci, nothing is required; if you swap
the workflows first, the old required check name disappears and every open PR becomes mergeable
with no gate at all.
copier copy overwrites. It does not merge, and the # --- end managed --- marker does not
protect anything here. That marker is a copier update affordance — it gives the three-way
merge a stable boundary on a repo that already has .copier-answers.yml. A first copy into a
repo that has never been rendered has no common ancestor to merge against, so there is no merge:
the rendered file replaces yours, whole.
Measured, on a repo whose .gitignore held # existing ignore, /bin and *.log:
| Command | What happens |
|---|---|
copier copy on a TTY |
Prompts per colliding file. Answering "yes" replaces it entirely; there is no "merge" answer. |
copier copy with no TTY (CI, a script, an agent) |
Aborts at the first collision: Interactive session required: Consider using --overwrite. Nothing is written — not even the non-colliding files. |
copier copy --overwrite |
Every colliding file is replaced without asking. All three lines above were gone. |
So the safe procedure is to make the overwrite reviewable rather than to avoid it. Git is what gives you the old content back:
$ cd ~/src/some-existing-repo
$ git status --porcelain # MUST be empty — this is the whole safety net
$ uvx copier copy --overwrite gh:jacaudi/template .
$ git diff # every line the render removed is a '-' hereThen, before committing, walk the - lines and reinstate what was yours. In the run above,
git diff --stat reported .gitignore | 92 insertions(+), 3 deletions(-) — the three deletions
being exactly the repo's own entries, put back below the # --- end managed --- marker where
copier update will preserve them from then on.
If the working tree is not clean, render into a scratch directory instead and copy files across by hand:
$ uvx copier copy --defaults gh:jacaudi/template /tmp/rendered
$ diff -ru . /tmp/renderedThe files most likely to collide are .gitignore, taskfile.yml, .golangci.yml, ruff.toml and
renovate.json. Everything under .github/workflows/ and scripts/ is meant to be taken whole.
Beyond the collisions:
- Answer
repo_namewith the real repository name. It is what the repo calls itself throughout. - Answer
image_nameif this repo's image is notghcr.io/<owner>/<repo>— and note that a chart repo is asked too, because a chart'sappVersionnames an image even when the repo builds none itself. - Uncomment the
dir:on thenode/python/goinclude intaskfile.ymlif that project is not at the repo root. They render commented out on purpose; Task creates adir:that does not exist, so a wrong one lints an empty directory and reports success. - Delete the old workflows. The template ships
ci.yamlplusci-*.yml; a leftoverbuild.ymlortest.ymlkeeps running alongside them. Delete them in the same commit that adds the new ones, so the repo is never running two pipelines at once. - Re-point the ruleset at
cifirst, exactly as in the greenfield flow, and for the same reason. - A repo with no chart, no image, or no Go answers
falseand simply does not receive those stages — see Variants. Do not render a service variant and then delete the build job by hand:ci.yaml'sneeds:list andcopier.yml's_excludestage list are two halves of one decision, and copier is what keeps them consistent.
Once the first ci run is green, the repo is fully self-contained: no other repository is involved
in any of its runs.
$ uvx copier update --trust.copier-answers.yml records the answers and the template revision, so an update re-renders with
the same answers and three-way-merges the result over your tree. Change an answer at the same time
with --data:
$ uvx copier update --trust --data has_chart=trueNothing forces an update. ci-lint.yml runs scripts/copier-freshness.sh on every run, which
compares the recorded _commit against the template's latest release and emits a warning —
never a failure. A hard failure would turn every repo in the fleet red the moment the template
moved, which is exactly the fleet-wide blast radius a template exists to avoid. The gap being
closed is "undetected", not "unblocked".
The questions copier asks. when means a question is only asked for some answers to an earlier one.
| Question | Type | Default | What answering it renders |
|---|---|---|---|
repo_name |
str | — | Used wherever the repo names itself. It does not seed image_name — that defaults to empty and each stage resolves ghcr.io/<owner>/<repo> at run time, where the owner is actually known. |
variant |
service / library |
service |
service adds ci-build.yml, ci-smoke.yml, ci-retag.yml, release-republish.yml, .dockerignore, .hadolint.yaml. library gets none of them. |
has_chart |
bool | false |
Adds ci-chart.yml, .taskfiles/chart.yml, and the release-chart job. |
lang_go |
bool | true |
Adds .golangci.yml, .taskfiles/go.yml. |
lang_node |
bool | false |
Adds .node-version, eslint.config.mjs, .taskfiles/node.yml. |
lang_python |
bool | false |
Adds ruff.toml, .taskfiles/python.yml. |
integration_kind |
none / postgres / envtest |
none |
(Go only) Non-none adds ci-integration.yml and wires an integration job into the ci gate. The stage runs setup-go and go test -tags=integration, so the question is not asked unless lang_go. |
extra_build_contexts |
yaml list | [] |
(service only) Non-empty adds ci-build-check.yml and a matrixed build-check job, one leg per context. |
image_name |
str | (empty) | (service or chart) Emitted as the image: input of every stage that takes one — ci-build.yml, ci-retag.yml, ci-chart.yml — and as release-republish.yml's IMAGE. Empty means ghcr.io/<owner>/<repo>, resolved at run time. |
chart_registry |
str | (empty) | (chart only) Emitted as ci-chart.yml's registry: input. Empty uses oci://ghcr.io/<owner>/charts. |
build_context |
str | . |
(service only) Emitted as ci-build.yml's context: input when it is not the repo root. |
A variant excludes the stages it does not call, and that is not cosmetic. Shipping a stage
nothing calls is dead weight; calling a stage that was excluded fails the run outright. Neither
failure is visible in the un-rendered template, which is why this repo renders every variant in CI
and runs scripts/local-refs.sh against each one.
Static linting alone does not cover it. Verified against actionlint v1.7.12: a missing local
reusable workflow is reported (could not read reusable workflow file for "./.github/workflows/ci-chart.yml"), but a missing local composite action is not — a workflow
whose only fault is uses: ./.github/actions/nope lints clean and exits 0. local-refs.sh covers
both, and also matches a commented-out uses: line, because a commented reference to a stage
this repo never received is a live landmine the moment somebody uncomments it. If a job should not
run, delete it rather than commenting it out.
integration_kind exists because services: is a job-level key. Neither a composite action nor
a called workflow can declare one on the caller's behalf, and the caller cannot inject one — so a
repo that needs a Postgres container or controller-runtime envtest assets gets its own local stage
rather than a bigger ci-lint.yml.
This is what service + has_chart renders. Every uses: is a file sitting beside it.
name: CI
on:
push:
branches: ['**']
workflow_dispatch:
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
permissions:
contents: read
jobs:
lint:
uses: ./.github/workflows/ci-lint.yml
build:
needs: [lint]
permissions:
contents: read
packages: write
uses: ./.github/workflows/ci-build.yml
smoke:
needs: [build]
permissions:
contents: read
packages: read
uses: ./.github/workflows/ci-smoke.yml
with:
image: ${{ needs.build.outputs.image }}
release:
needs: [smoke]
if: ${{ github.ref == 'refs/heads/main' }}
permissions:
contents: write
pull-requests: write
uses: ./.github/workflows/ci-release.yml
with:
chart-package: charts
secrets:
APP_ID: ${{ secrets.APP_ID }}
APP_PRIVATE_KEY: ${{ secrets.APP_PRIVATE_KEY }}
release-image:
needs: [build, release]
if: ${{ needs.release.outputs.release_created == 'true' }}
permissions:
contents: read
packages: write
uses: ./.github/workflows/ci-retag.yml
with:
source: ${{ needs.build.outputs.image }}
version: ${{ needs.release.outputs.tag_name }}
release-chart:
needs: [release]
if: ${{ needs.release.outputs.chart_release_created == 'true' }}
permissions:
contents: read
packages: write
uses: ./.github/workflows/ci-chart.yml
with:
version: ${{ needs.release.outputs.chart_version }}
secrets:
APP_ID: ${{ secrets.APP_ID }}
APP_PRIVATE_KEY: ${{ secrets.APP_PRIVATE_KEY }}
ci:
needs: [lint, build, smoke]
if: ${{ !cancelled() }}
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- name: Assert required stages
env:
LINT: ${{ needs.lint.result }}
BUILD: ${{ needs.build.result }}
SMOKE: ${{ needs.smoke.result }}
run: |
set -euo pipefail
fail=0
for stage in lint:"${LINT}" build:"${BUILD}" smoke:"${SMOKE}"; do
name="${stage%%:*}"; result="${stage##*:}"
printf '%-12s %s (must be success)\n' "${name}" "${result}"
[ "${result}" = "success" ] || fail=1
done
[ "${fail}" = "0" ] || { echo "::error::one or more required stages did not pass"; exit 1; }Three properties of that ci job are load-bearing, and copier renders all three for every variant:
- It is a regular job, never a job that calls a reusable workflow. A calling job does not report
a check named after itself; it reports
<caller-job> / <inner-job>, a two-part context no single-context branch ruleset can require. A plainruns-onjob with aneeds:list is what makes one stable check name exist, no matter how many stages the repo gains or loses later. if: ${{ !cancelled() }}is mandatory. Without it an upstream failure leavesciskipped, and GitHub counts a skipped required check as satisfied — the gate silently stops gating.!cancelled()rather thanalways(): a cancelled run belongs to a superseded commit, andalways()would both make this job uncancellable and paint a red X on every superseded commit.- The assertion is positive. A needed job can come back
skippedorcancelled, neither of which isfailure; only an explicitsuccesspasses.
Every table below is extracted from the shipped on.workflow_call block and compared against this
file. Change a stage's interface and this document fails CI until it is updated — see
Verifying this document.
A (empty) default means the declared default is the empty string, which each stage interprets:
image falls back to ghcr.io/<owner>/<repo>, registry to oci://ghcr.io/<owner>/charts,
version/ref mean "not a release build", file means "auto-resolve the Dockerfile", and
chart-package means "this repo ships no chart".
| Kind | Name | Type | Required | Default / value |
|---|---|---|---|---|
| input | go-test-flags |
string |
no | -race |
| permission | contents |
— | — | read |
Static analysis plus unit tests, each tool as its own step so GitHub attributes annotations,
timing and failures to the thing that produced them. Runs scripts/local-refs.sh, the copier
freshness warning, actionlint, yamllint, hadolint, the Go suite (golangci-lint fmt --diff,
go mod tidy -diff, golangci-lint run, govulncheck, go test), Helm dependency resolution, a
chart drift gate, helm lint + helm template, and the Python and JS suites — each skipped by
detection, never by an input.
No project directory is ever an input. Python and JS directories are discovered at run time by
scripts/discover-dirs.sh, and Go's module directory comes from the setup action's godir output —
every Go step runs with it as working-directory, because setup-go installs a toolchain and does
not change directory, so a module under svc/ would otherwise leave go test ./... running in the
repo root against no module at all. That is what lets one byte-identical file serve every repo.
There is also no chart-path input, deliberately: the chart is always at charts/, so the
drift gate's pathspec is always correct. Before that convention was fixed, an unmatched pathspec
exited 0 and the gate passed while checking nothing.
go-test-flags reaches the shell through env:, never direct interpolation.
| Kind | Name | Type | Required | Default / value |
|---|---|---|---|---|
| input | version |
string |
no | (empty) |
| input | ref |
string |
no | (empty) |
| input | image |
string |
no | (empty) |
| input | context |
string |
no | . |
| input | manifest-file |
string |
no | .github/release-please-manifest.json |
| secret | APP_ID |
— | no | — |
| secret | APP_PRIVATE_KEY |
— | no | — |
| output | image |
— | — | — |
| permission | contents |
— | — | read |
| permission | packages |
— | — | write |
Multi-arch (linux/amd64,linux/arm64) build pushed to GHCR, with outputs.image pinned by
digest — that digest is what ci-smoke.yml tests and what ci-retag.yml republishes, so the
thing released is byte-identical to the thing tested.
Never matrix this stage. A matrixed job's outputs are last-leg-wins with no guaranteed
ordering, so smoke and retag would silently receive the wrong digest. Use ci-build-check.yml for
auxiliary images.
There is no platforms: and no file: input here, deliberately — both arches always, and the
Dockerfile path auto-resolves (<context>/Dockerfile, then <context>/Containerfile) or fails
loudly. context is kept, because a repo whose Dockerfile is not at the root has nowhere else to
say so; that is what the build_context answer renders into.
manifest-file must match whatever ci-release.yml was given. When version is set, this stage
reads the manifest and refuses to publish a tag from a checkout that reports a different
version.
| Kind | Name | Type | Required | Default / value |
|---|---|---|---|---|
| input | context |
string |
yes | — |
| input | file |
string |
no | (empty) |
| input | platforms |
string |
no | linux/amd64 |
| input | timeout-minutes |
number |
no | 30 |
| permission | contents |
— | — | read |
Builds a Dockerfile to prove it compiles and never pushes it. For repos with a sidecar, a
renderer, or any second image that must stay buildable without being published. Rendered only when
extra_build_contexts is non-empty, as a matrix with one leg per context:
build-check:
needs: [lint]
strategy:
fail-fast: false
matrix:
include:
- context: radar
- context: renderer
uses: ./.github/workflows/ci-build-check.yml
with:
context: ${{ matrix.context }}This stage declares no outputs: block, and that absence is the contract. Because it produces
nothing a downstream job can read, a caller may safely matrix it — which is precisely what
ci-build.yml may not do. A matrix job is still one job in the graph, so
needs.build-check.result is success only if every leg succeeded, and fail-fast: false means
every broken image reports in one run rather than one per re-run.
platforms is single-arch by default on purpose: the requirement is that the Dockerfile
compiles, not that it is distributed, and emulating arm64 is actively harmful for some images
(compiling arm-pyart's C extensions under QEMU reproducibly crashes cc1).
| Kind | Name | Type | Required | Default / value |
|---|---|---|---|---|
| input | image |
string |
yes | — |
| secret | APP_ID |
— | no | — |
| secret | APP_PRIVATE_KEY |
— | no | — |
| permission | contents |
— | — | read |
| permission | packages |
— | — | read |
A thin harness: logs in to GHCR, installs Task, and runs task smoke with IMAGE set to the
digest-pinned reference. The consumer owns what smoke means, because that is repo knowledge. This
is the one place CI still calls the taskfile.
| Kind | Name | Type | Required | Default / value |
|---|---|---|---|---|
| input | config-file |
string |
no | .github/release-please-config.json |
| input | manifest-file |
string |
no | .github/release-please-manifest.json |
| input | chart-package |
string |
no | (empty) |
| secret | APP_ID |
— | yes | — |
| secret | APP_PRIVATE_KEY |
— | yes | — |
| output | release_created |
— | — | — |
| output | tag_name |
— | — | — |
| output | version |
— | — | — |
| output | major |
— | — | — |
| output | chart_release_created |
— | — | — |
| output | chart_version |
— | — | — |
| permission | contents |
— | — | write |
| permission | pull-requests |
— | — | write |
release-please as a stage. On an ordinary push to main it opens or updates the release PR
(CHANGELOG + version bump); on the merge of that PR it creates the tag and the GitHub Release.
The main-only gate lives in the caller's if:, never here — a stage does not get to decide
which branch a repo releases from.
chart-package resolves multi-package release-please's <path>-- output prefix. With the chart at
charts/, that prefix is charts-- (not chart--), which is why has_chart renders
chart-package: charts and the caller reads chart_release_created / chart_version. If
chart-package names a package the config does not declare, the stage fails loudly rather than
letting the chart silently never publish; the check is guarded on a release actually being cut, so
ordinary pushes to main stay green.
| Kind | Name | Type | Required | Default / value |
|---|---|---|---|---|
| input | source |
string |
yes | — |
| input | version |
string |
yes | — |
| input | image |
string |
no | (empty) |
| secret | APP_ID |
— | no | — |
| secret | APP_PRIVATE_KEY |
— | no | — |
| output | image |
— | — | — |
| permission | contents |
— | — | read |
| permission | packages |
— | — | write |
A registry-side manifest-list copy of the digest ci-build.yml already produced, applied as
vX.Y.Z, vX.Y, vX and latest. Not a rebuild: container builds are not bit-reproducible, so a
rebuild would publish a digest nothing has smoke-tested.
It first asserts the tag points at this run's commit and refuses to retag otherwise, then re-inspects the published tag and fails if the shipped digest differs from the tested one.
| Kind | Name | Type | Required | Default / value |
|---|---|---|---|---|
| input | chart-path |
string |
no | charts |
| input | version |
string |
yes | — |
| input | registry |
string |
no | (empty) |
| input | image |
string |
no | (empty) |
| secret | APP_ID |
— | no | — |
| secret | APP_PRIVATE_KEY |
— | no | — |
| output | chart |
— | — | — |
| permission | contents |
— | — | read |
| permission | packages |
— | — | write |
Packages charts/ and pushes it to oci://ghcr.io/<owner>/charts/<name> by default. Before
publishing, it verifies that the image named by the chart's committed appVersion actually exists —
a chart naming an image that was never built installs cleanly and then leaves every pod in
ImagePullBackOff.
version must be bare semver — see Two tag conventions.
registry and image exist for the repos that do not match the convention: a chart whose image is
not repo-named cannot pass the appVersion check without image, and a chart already published at
a different OCI path needs registry, so that moving namespace is a decision rather than a silent
break of every published helm install URL. The chart_registry answer is what renders it.
Rendered only when integration_kind is postgres or envtest. It takes no inputs and no
secrets, so it has no interface table: it is a stage rather than a step purely because services:
and envtest asset setup are job-level concerns. postgres renders a services: block running
postgres:18; envtest renders setup-envtest with a pinned control-plane version and runs
go test ./... -race -tags=integration with KUBEBUILDER_ASSETS set.
Chart tags are BARE semver. Image tags are v-PREFIXED.
| Tag | Example | |
|---|---|---|
Helm chart (ci-chart.yml, version) |
bare semver, no v |
oci://ghcr.io/jacaudi/charts/foo:1.2.3 |
Container image (ci-build.yml / ci-retag.yml, version) |
v-prefixed |
ghcr.io/jacaudi/foo:v1.2.3 |
Helm resolves "latest" by listing tags and picking the highest valid SemVer, and SemVer forbids
a leading v — it is a git convention, not part of the spec. A v-prefixed chart tag therefore
yields zero valid versions and the chart is unusable without an explicit --version.
This failure is completely silent on the publishing side. Verified empirically:
helm package --version v1.2.3 succeeds, helm push succeeds, the workflow goes green, and the tag
appears in the registry. Nothing on the publishing side reports a problem. Only the consumer ever
finds out, and what they see is:
Error: unable to locate any tags in provided repository
So ci-chart.yml's version comes from release-please's chart package output (chart_version,
bare) and ci-retag.yml's from tag_name (v-prefixed) — which is exactly what ci.yaml renders.
Never normalise one into the other. The v that ci-chart.yml adds internally runs the other
way, putting a v back onto appVersion before looking the image up, and is also correct, because
images are v-prefixed.
APP_ID and APP_PRIVATE_KEY, under exactly those names, as repo or org secrets. They are a
consumer concern: nothing in this template repo runs a release, so they matter at the moment a
repo is onboarded and not before.
They are not uniformly required, and the difference between stages is deliberate:
| Stage | Declared | Behaviour without them |
|---|---|---|
ci-release.yml |
required: true, no GITHUB_TOKEN fallback |
The token step fails immediately and loudly. |
ci-chart.yml |
required: false, falls back to GITHUB_TOKEN |
The push fails with denied: permission_denied. Optional in the interface, needed in practice. |
ci-build.yml, ci-smoke.yml, ci-retag.yml |
required: false, falls back to GITHUB_TOKEN |
Fine — GITHUB_TOKEN reaches the repo's own GHCR namespace. |
ci-release.yml has no fallback on purpose, and it must never be given one. GITHUB_TOKEN
cannot trigger workflows. A silent fallback opens a release PR that gets no run, therefore never
reports ci, and is therefore unmergeable forever with no error anywhere explaining why.
Failing on an empty app-id converts that into a loud failure at the right place.
Do not pass the App to ci-build.yml, ci-smoke.yml or ci-retag.yml unless you actually need
to push or pull outside the repo's own GHCR namespace. ci-build.yml logs in with the App token
whenever APP_ID is non-empty, and a typical App installation has no package-write grant, so
passing it there actively fails with denied: permission_denied: installation not allowed to Write organization package. That is why ci.yaml renders secrets: on release and release-chart
only. ci-chart.yml is the inverse case: ghcr.io/<owner>/charts/<name> is a different package
namespace from ghcr.io/<owner>/<repo>, which is all GITHUB_TOKEN can reach, so a real chart push
needs the App with package-write.
The template ships all of these. The obligation is not to acquire them — it is to not delete them, and to reconcile rather than discard them when adopting an existing repo.
| File | Why the pipeline needs it |
|---|---|
.yamllint.yml |
ci-lint.yml runs a bare yamllint ., which falls back to yamllint's default config when the repo ships none — and that default errors on any line over 80 characters and warns on a missing ---. The shipped one is the fleet baseline. |
.node-version (JS/TS repos) |
The setup action passes node-version-file: .node-version to actions/setup-node, which fails outright when the file is absent. |
eslint.config.mjs (JS/TS repos) |
eslint 9 has no built-in fallback: with no flat config anywhere it exits 2 with couldn't find an eslint.config.(js|mjs|cjs) file and lints nothing. The shipped one imports nothing, so it works at the repo root before npm ci has run — flat-config lookup walks up from the working directory. A project needing plugins keeps its own beside its package.json, which wins by being nearer. |
.gitignore carrying charts/*.tgz and **/charts/*.tgz (chart repos) |
The chart drift gate's verdict includes untracked files, and helm dependency build writes charts/charts/<dep>.tgz one step earlier in the same job. charts/*.tgz alone does not match that nested path, so without **/charts/*.tgz the gate fails on a tarball CI itself created. |
.github/release-please-config.json + -manifest.json |
ci-release.yml and ci-build.yml both read them, at those exact paths by default. |
taskfile.yml with a smoke target |
ci-smoke.yml runs task smoke with IMAGE set. |
.copier-answers.yml |
copier update re-renders from it, and scripts/copier-freshness.sh reads _commit out of it. Delete it and the repo silently stops being updatable. |
scripts/ |
Every gate the stages run lives there — the consumer's own files, at the consumer's own commit. |
A Dockerfile or Containerfile under the build context (service repos) |
ci-build.yml resolves <context>/Dockerfile, then <context>/Containerfile, then fails loudly. |
charts/ — plural, always (chart repos) |
The drift gate and ci-chart.yml's default both assume it. |
Everything under template/.github/workflows/ in this repo is inert, and that is a property of
the layout rather than a trick: GitHub only reads workflows from .github/workflows/ at the
repository root. A file two directories down is just a file. The moment copier renders it into a
consumer at that repo's root it is a live workflow — unchanged, byte for byte.
The one workflow that is live here is .github/workflows/self-test.yml, this repo's own gate. It
is also why scripts/ at the root holds template-repo tooling (readme-iface.sh,
render-examples.sh) while template/scripts/ holds the gates that ship.
Six fully rendered repos, one per shape this fleet actually has:
| Example | Answers beyond the defaults |
|---|---|
service-go-chart |
variant=service lang_go=true has_chart=true |
library-go |
variant=library lang_go=true |
service-node |
lang_node=true build_context=web image_name=ghcr.io/jacaudi/service-node-web |
service-python |
lang_python=true extra_build_contexts=[radar,renderer] |
operator-envtest |
has_chart=true integration_kind=envtest chart_registry=oci://ghcr.io/jacaudi/operator-envtest-chart image_name=ghcr.io/jacaudi/operator-envtest-controller |
library-chart |
variant=library lang_go=false has_chart=true image_name=ghcr.io/jacaudi/kiwix-downloader — a repo that is nothing but a chart, naming an image built elsewhere |
They are generated, not written: scripts/render-examples.sh holds the answer matrix, and
self-test.yml re-renders it and diffs the result. A template change that breaks a variant fails
this repo's CI instead of surfacing the next time somebody runs copier for real. The non-default
answers above are not decoration either — each one renders a branch of ci.yaml.jinja that a
matrix of all-defaults renders would leave permanently unexercised.
Each example's .copier-answers.yml has _commit and _src_path replaced with placeholders. Both
record where and when the render happened rather than what it produced: _commit changes with
every commit to this repo, and copier stashes a dirty worktree into a throwaway commit first, so a
local render produces a different sha every time. Normalising the two is what makes the diff a gate
rather than permanent noise.
Regenerate them after any change under template/:
$ sh scripts/render-examples.sh examplesself-test.yml runs three jobs and aggregates them into a regular job named ci — the same shape
it renders for consumers, for the same reason.
- static gates — actionlint, yamllint, shellcheck, JSON parse, and
scripts/readme-iface.sh. The POSIX and bash shell dialects are checked separately, and the split is load-bearing:detect.shuses arrays anddetect-test.shusesset -o pipefail, neither of which is POSIX, while everything undertemplate/scripts/must run under the runner's dash. - shell harness — every suite under
tests/, found by glob, not enumerated, so adding one needs no edit to the workflow. They cover the repo-shape detector, the chart scripts, the discovery scripts, the shipped gates, the template's own shape, and this README. - rendered variants — installs copier, renders every example, and for each one runs
actionlint,yamllint,scripts/local-refs.sh,scripts/run-scripts-exist.shandscripts/run-interpolation.shinside the rendered tree, then diffs the whole set againstexamples/. A rendered repo must never call a stage that was excluded, and never ship one nothing calls. It also runsscripts/image-consistency.shover each variant — the one check here that reads a whole rendered repo at once, becauseimage_namefeeds four stages and an absentimage:is not silence but a different repository.ci.yamlpassed it to one of them, so a release published its tags where nothing had been built, and every other gate stayed green.
The stage tables above are not prose — they are the shipped interface, and they are checked against
template/.github/workflows/ci-*.yml rather than maintained by hand. The check is
scripts/readme-iface.sh, run by the static gates and covered by
tests/readme-iface-test.sh. It does two things:
- The tables. It parses every
| input |/| secret |/| output |/| permission |row out of this README, extracts the same facts withyq, and diffs the two sets. A missing row, an extra row, a wrong type, a stale default or arequiredthat flipped all fail. - The examples. It extracts every
```yamlfence, lints it withtemplate/.yamllint.yml— the very config a rendered consumer runsyamllint .with — then resolves eachuses:to a real stage here and asserts that everywith:key is a declared input, everysecrets:key a declared secret, and every required input and required secret is actually passed. A cross-repouses:is a hard error rather than a skip: this template ships only./…references, and quietly ignoring the old form is how a README describing a layout nothing renders survived unnoticed.
Linting the fences is not decoration. yamllint . cannot see inside a markdown fence, so the
copy-pasteable ci.yaml above is the one piece of YAML here that nothing else checks — and an
earlier version of this file shipped it with fourteen too many spaces inside braces errors from a
flow mapping. Every consumer renders that config into their own tree and then runs yamllint . over
it, so their first ci run would have been red before they changed a line.
jacaudi/github-actions is a separate, still-live repository, serving four consumers at
@v0.20.3. Nothing in this template touches it, and nothing here is a rename of it. A repo that has
not adopted this template keeps calling jacaudi/github-actions@v0.20.3 and keeps working; the two
do not interact.
Adopting this template is therefore a per-repo decision, made by rendering it — see Adopting an existing repo. When you migrate one, the things most likely to bite are:
- The branch ruleset must name plain
ci. If the oldci.yamlproduced a nested check name, update the ruleset first. APP_ID/APP_PRIVATE_KEYmust exist in that repo, and the App must be installed on it.- Chart versions must arrive bare; image versions
v-prefixed. - Delete the old workflows in the same commit that adds the rendered ones.
There is no shared repo to pin, no @v1 tag to track, and no .ci-shared checkout anywhere in what
gets rendered. If you find a reference to any of those, it is stale — tests/template-shape-test.sh
exists to make sure none of it comes back.