Skip to content

Latest commit

 

History

54 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

jacaudi/template

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.


What a render puts in your repo

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


Quick start — a new repo (greenfield)

$ 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:

  1. Point the branch ruleset at exactly ci — before swapping workflows in, never after.
  2. Set APP_ID and APP_PRIVATE_KEY as repo or org secrets, under exactly those names. See The App secrets.
  3. Seed .github/release-please-manifest.json from git describe --tags --abbrev=0, so release-please does not propose 0.1.0 for a repo already at v3.4.1.
  4. 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.


Adopting an existing repo (brownfield)

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 '-' here

Then, 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/rendered

The 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_name with the real repository name. It is what the repo calls itself throughout.
  • Answer image_name if this repo's image is not ghcr.io/<owner>/<repo> — and note that a chart repo is asked too, because a chart's appVersion names an image even when the repo builds none itself.
  • Uncomment the dir: on the node/python/go include in taskfile.yml if that project is not at the repo root. They render commented out on purpose; Task creates a dir: that does not exist, so a wrong one lints an empty directory and reports success.
  • Delete the old workflows. The template ships ci.yaml plus ci-*.yml; a leftover build.yml or test.yml keeps 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 ci first, exactly as in the greenfield flow, and for the same reason.
  • A repo with no chart, no image, or no Go answers false and simply does not receive those stages — see Variants. Do not render a service variant and then delete the build job by hand: ci.yaml's needs: list and copier.yml's _exclude stage 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.


Staying up to date — copier update

$ 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=true

Nothing 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".


Variants

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.


The orchestrator and the ci gate

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 plain runs-on job with a needs: 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 leaves ci skipped, and GitHub counts a skipped required check as satisfied — the gate silently stops gating. !cancelled() rather than always(): a cancelled run belongs to a superseded commit, and always() 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 skipped or cancelled, neither of which is failure; only an explicit success passes.

The seven stages

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

Stage 1 — lint and unit tests

ci-lint.yml

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.

Stage 2 — build and push the image

ci-build.yml

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.

Compile-only — auxiliary images

ci-build-check.yml

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

Stage 3 — smoke test

ci-smoke.yml

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.

Stage 4 — release

ci-release.yml

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.

Release image — retag, don't rebuild

ci-retag.yml

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.

Release chart

ci-chart.yml

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.

The eighth file — ci-integration.yml

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.


Two tag conventions, opposite on purpose

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.


The App secrets

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.


What the consumer must keep

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.

Working but deactivated

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.


examples/

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 examples

This repo's own CI

self-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.sh uses arrays and detect-test.sh uses set -o pipefail, neither of which is POSIX, while everything under template/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.sh and scripts/run-interpolation.sh inside the rendered tree, then diffs the whole set against examples/. A rendered repo must never call a stage that was excluded, and never ship one nothing calls. It also runs scripts/image-consistency.sh over each variant — the one check here that reads a whole rendered repo at once, because image_name feeds four stages and an absent image: is not silence but a different repository. ci.yaml passed it to one of them, so a release published its tags where nothing had been built, and every other gate stayed green.

Verifying this document

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:

  1. The tables. It parses every | input | / | secret | / | output | / | permission | row out of this README, extracts the same facts with yq, and diffs the two sets. A missing row, an extra row, a wrong type, a stale default or a required that flipped all fail.
  2. The examples. It extracts every ```yaml fence, lints it with template/.yamllint.yml — the very config a rendered consumer runs yamllint . with — then resolves each uses: to a real stage here and asserts that every with: key is a declared input, every secrets: key a declared secret, and every required input and required secret is actually passed. A cross-repo uses: 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.


Relationship to jacaudi/github-actions

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 old ci.yaml produced a nested check name, update the ruleset first.
  • APP_ID / APP_PRIVATE_KEY must 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.

About

Copier template for the jacaudi CI/CD pipeline. Generate a new repo or retrofit an existing one.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages