Delivery pipeline
How a change goes from a local commit through git hooks and CI to signed images, a release and the demo environment.
This page explains the machinery that takes a change from a commit on a branch to signed container images, a tagged release and the demo environment. It describes what the repository contains today, file by file. The rules this machinery enforces are in sdlc.md and aren’t repeated here. Where the two disagree, Not built yet says so.
Concepts#
One entry point. Make, written by Stuart Feldman at Bell Labs in 1976, gave a project one file that names every build step. Task follows the same idea, with YAML instead of Makefile syntax. In OpenFief, Taskfile.yml is the only place a command is written down. A developer, a git hook and a CI job all run task <target>, so they can’t drift into three slightly different scripts. The Taskfile also pins the version of every tool it calls.
Shift-left checks. “Shift left” (Larry Smith, 2001) means running a check earlier, when a failure is still cheap. In git, the earliest point is the pre-commit hook: the commit doesn’t exist until its checks pass. lefthook manages the hooks. It writes small scripts into .git/hooks that read lefthook.yml.
Conventional Commits. A commit subject has the form type(scope)!: summary, as in feat(ledger): add lots. The convention grew out of the Angular project’s commit guidelines. The type tells a machine what kind of change a commit is, and ! or a BREAKING CHANGE: footer marks a breaking one. Here, the type decides which section of the release notes a commit goes in.
CalVer and SemVer. Semantic Versioning (MAJOR.MINOR.PATCH) is a compatibility promise: only a new major version may break you. That matters to code that depends on an API. Calendar Versioning puts the date in the number, as Ubuntu’s 24.04 does, which tells a user how recent a release is. The product uses CalVer (2026.11.0). The protobuf contracts and SDKs are meant to use SemVer.
DCO. The Developer Certificate of Origin came from the Linux kernel in 2004, after the SCO lawsuits made the origin of code a legal question. Each commit carries a Signed-off-by: Name <email> trailer (git commit -s), by which the author certifies they may contribute it. It’s lighter than a contributor licence agreement: there’s nothing to sign beyond the commit itself. D-5 in DESIGN.md requires it.
Keyless signing. cosign is the signing tool of Sigstore, a Linux Foundation project launched in 2021. Classic signing needs a long-lived private key that someone must guard and rotate. Keyless signing replaces the key with an identity. The CI job asks GitHub for an OIDC token that names the repository, workflow and ref. Sigstore’s certificate authority, Fulcio, exchanges it for a certificate that is valid for about ten minutes. cosign signs with a throwaway key and records the signature in Rekor, a public append-only log. A verifier checks who signed (the workflow identity) rather than which key.
Digest pinning. An image tag such as :main is a movable pointer. A digest (@sha256:...) is the hash of the content, so it changes if one byte changes. Pinning a digest means you run exactly what was tested and signed. Here, GitHub Actions are pinned by commit SHA, Dockerfile base images by digest, and cosign signs the digest the build produced. D-17 requires plugin images to be pinned by digest.
GitOps. In a push deploy, CI holds a credential for the cluster and runs kubectl apply. GitOps, a name Weaveworks gave the practice in 2017, turns this around: an agent inside the cluster pulls the desired state from a versioned source and keeps the cluster matching it. Flux, a CNCF project that grew out of Weaveworks, is such an agent. Nothing outside the cluster needs a way in, and the source’s history is the deploy log. The demo’s Flux pulls from git and from a signed OCI artifact: a registry entry that holds manifests instead of an image.
Supply-chain hygiene with Renovate. Exact pins stop surprise upgrades, but they go stale. Renovate is a bot that finds pinned versions and opens pull requests to bump them, so each upgrade is a reviewed and tested change. It can’t see a pin it can’t parse, so the Taskfile and workflows mark versions with # renovate: comments.
Traceability gate. In safety-critical engineering, a traceability matrix links each requirement to the code that implements it and the test that verifies it. kibi keeps that record in .kb/ and checks it with Prolog rules. Its gate in the pre-commit hook refuses a commit whose behavioral code has no owning requirement or no covering test.
How it works#
The Taskfile#
Every step is a task target, and task --list shows them with descriptions. Three conventions recur:
- Pinned tools in
.bin/. Thevarsblock pins golangci-lint, govulncheck, lefthook, Task itself, actionlint, controller-gen, Hugo, buf and its three generators (Go, Connect and the read API’s OpenAPI), and for the demo hcloud, kustomize and gojq. The internaltool:*targetsgo installthem into.bin/, and astatuscheck skips the install unless the version changed. Go tools are built with the toolchain ingo.mod(GO_TOOLCHAIN, nowgo1.27.3), because golangci-lint refuses code that targets a newer Go than it was built with. The envtest API server and etcd come from envtest-assets.sh, which checks each archive against a pinned SHA-512. The flux CLI can’t be built withgo install, sotool:fluxdownloads its release archive and checks a pinned sha256. The k3s and Flux versions a demo machine installs are pinned here too, with their sha256 sums, and rendered into its cloud-init. The end-to-end suite reads two more pins:CNPG_VERSIONwith the sha256 of CloudNativePG’s release manifest, which the internale2e:cnpgtarget downloads into.bin/e2e/and checks, andK3S_VERSIONagain, as the k3s image of the k3d cluster (task e2e:k3s-image). - Explicit paths. Tasks call
{{.BIN}}/golangci-lint, notgolangci-lint, so a global install can’t shadow the pin. - Skip until it exists. Some targets print “skipping” and succeed while their input is missing:
GO_GUARD(nogo.mod) andPROTO_GUARD(no.protofiles). The pipeline was built ahead of the product. Today nothing skips:go.modand the contracts inproto/exist, ande2eruns the suite intest/e2e.
task setup runs once per clone. It installs the tools, runs npm ci for kibi, installs the lefthook hooks, and registers kibi’s merge driver for the .kb files named in .gitattributes. CI runs task setup:ci instead, which skips lefthook and the merge driver and compiles the branch’s knowledge base.
check (CI) and check:commit (the hook) run the same steps in the same order, with two differences: CI also runs the docs staleness gate over the pushed range (docs:fresh; the hook’s version runs at commit-msg), and the kibi step at the end differs. Task runs a cmds list in sequence and stops at the first failure. “Each module” below means GO_MODULES: the core (.) and the Go SDK (sdk/go).
| Step | What it runs | Fails when |
|---|---|---|
gen:check | task gen (controller-gen writes deepcopy code, CRDs and the operator’s Role), then gen-check.sh | a regenerated file differs from the git index or is untracked |
plugins:labels:check | plugin-labels.sh check; task plugins:labels writes the labels | a first-party plugin’s io.openfief.plugin.manifest Dockerfile label, or a demo Plugin’s spec.manifest, differs from its plugins/<name>/manifest.json (D-42) |
fmt:check | golangci-lint fmt --diff in each module (gofumpt with extra rules, goimports) | a file would be reformatted |
lint | golangci-lint config verify, then golangci-lint run in each module, with the e2e build tag so the end-to-end suite is linted too | any finding; .golangci.yml enables every linter except a commented list |
lint:ci | actionlint on the workflows, with its shellcheck and pyflakes integrations off | a workflow error |
proto | buf lint, buf format --diff, proto-breaking.sh (with the base’s own buf.lock), proto-check.sh | a lint or format issue, a breaking change against the base, or a stale sdk/go/gen or gen/openapi (the read API’s OpenAPI document, generated from its protobuf and read_service.openapi-base.yaml) |
vuln | vulncheck.sh: govulncheck in each module, then npm audit --audit-level=high | a finding, or an unreachable vulnerability database |
test | go test ./... in each module with the envtest binaries, then the eleven scripts/tests/*.test.sh | any failure |
docs:check | check-docs.sh | an undefined or duplicate GR, D, P, T, OQ or R ID, or a changed ground-rule line |
docs:build | docgen writes the reference pages, then Hugo and docs-diagrams.mjs build the site (docs.md) | a broken link, a diagram that doesn’t render, or a docs page naming a kibi ID or task that doesn’t exist |
docs:fresh (CI only) | git log over the range from DOCS_BASE, piped into docgen fresh | covered code changed without a page covering it and without a Docs-Unchanged: trailer, or a Go package no page covers |
demo:validate | validate.sh: kustomize build of deploy/demo, of every git path its Flux Kustomizations apply, and of deploy/demo/openfief | a layer doesn’t build, an image isn’t pinned by digest, an OCI source doesn’t require a cosign signature, or a substituting layer uses a variable cloud-init doesn’t define |
kibi:check or kibi:staged | kibi check (CI); kibi check-generated --staged --changed-only and kibi check --staged (hook) | a knowledge-base rule violation |
Two steps compare against a base revision: the ground-rule check reads DOCS_BASE, and proto:breaking reads PROTO_BASE, then DOCS_BASE. Both default to HEAD, which locally means “against the last commit”. CI sets DOCS_BASE to the pull request’s base.
Git hooks#
lefthook.yml defines five hooks. Each calls one task with .bin/ first on PATH, so the pinned Task runs:
pre-commitrunstask check:commit.commit-msgrunstask docs:fresh:commitwith the message file: the docs staleness gate on the staged files. It skips merge commits; CI checks the merged commits one by one.post-checkoutrunstask kibi:syncfor a branch checkout (lefthook’s{3}argument is1), not for a file checkout.post-mergerunstask kibi:sync.post-rewriterunstask kibi:syncafter a rebase, not after an amend.
kibi compiles one store per branch, and the sync hooks keep it in step with the branch you’re on. kibi’s own git hooks stay uninstalled, so lefthook alone owns .git/hooks.
The hook checks the working tree, not a clean copy of the index. Go tests, lint and the docs check read files on disk, so an unstaged edit can make a commit pass locally and fail in CI. Only gen:check and the kibi gate compare against the index.
The kibi gate#
On commit, kibi:staged runs two commands. kibi check-generated --staged --changed-only regenerates kibi’s symbol manifest from the staged bytes and compares it with what is staged; it skips the work when no staged path can affect a symbol. kibi check --staged then runs kibi’s rules on the staged change, including the rule that blocks a behavioral symbol with no implements requirement or no covered_by test. kibi’s own help marks --staged as experimental.
In CI, the setup action sets KIBI_BRANCH, because actions/checkout leaves a detached HEAD and kibi won’t write a store without a branch name. setup:ci then runs kibi sync, and task check ends with kibi check over the whole knowledge base. CI is therefore stricter than the hook: it also catches problems outside the staged change.
CI on pull requests and main#
ci.yml runs on pull requests to main and on pushes to main. The workflow starts from permissions: {}, and each job asks only for what it needs. A newer push cancels a running pull-request run, but runs on main always finish, so every merge is built.
Each job first calls the composite setup action. It installs Go from go.mod, installs Task at the Taskfile’s TASK_VERSION and sets KIBI_BRANCH. Task is the one tool installed outside a task target, since no target can run before Task exists. By default the action also installs Node and runs task setup:ci; jobs that only need Task pass tools: 'false'.
checkrunstask checkwith full history.DOCS_BASEis the pull request’s base SHA, or the previousmain(github.event.before) on a push.test-pgrunstask test:pg. test-pg.sh starts a throwaway PostgreSQL from the runner’s binaries on a Unix socket, each test creates a fresh database with every migration applied, and the server is removed afterwards. The same run loads the demo dataset end to end throughcmd/demo-seed, and startscmd/coreon a fresh database to call its read API with a script token.dcoruns on pull requests only:task dco BASE=<base sha> HEAD=<head sha>. dco-check.sh walks each non-merge commit in the range and requires aSigned-off-bytrailer that matches the commit’s author name and email exactly.e2eis a matrix overk3dandkind(D-12), and one failure doesn’t cancel the other; it has 30 minutes. A third-party action creates the cluster. The k3d cluster runs the demo machine’s k3s: the imagetask e2e:k3s-imagenames fromK3S_VERSION, with flannel, k3s’s built-in kube-router NetworkPolicy controller, the bundled Traefik and servicelb, and--secrets-encryption, as the machine’s cloud-init sets it. kind runs its default CNI, kindnet, which also enforces NetworkPolicy.task e2e:images CLUSTER=<k3d|kind>then builds thecore,operator,egress-proxyanddemo-seedimages from the checkout withdocker build(e2e-images.sh), tags themopenfief.test/<name>:e2eand loads them into the cluster (k3d image import,kind load docker-image), so a pull request tests its own code.task e2eruns assert-local-cluster.sh, which refuses any API server not on127.0.0.1,localhost,0.0.0.0or[::1], fetches the CloudNativePG manifest, and runsgo test -tags e2e -v ./test/e2e/...with the pinned kustomize first onPATH.The suite installs what the demo machine runs, in the order of its Flux layers: CloudNativePG at the operator version of the demo’s chart,
deploy/crds, the demo’s databasedeploy/demo/database(on kind, a copy of the default storage class namedlocal-path), the demo’s app layerdeploy/demo/openfiefthroughtest/e2e/overlays/openfief, which only swaps the images and admits the suite’s client pod likecore-from-traefik, and on k3s the demo’s Ingress throughtest/e2e/overlays/routes(no TLS, a.testhost). It then checks that the core becomes available and answers/readyzthrough its Service and, on k3s, through Traefik; that a pod no policy admits can’t connect; that thedemo-seedJob completes; that a script token from/core token issuelists the three demo accounts, each valued through today; and that a call without a token gets 401. On a failure the test logs lines startingdiagnostics:: every pod and event, the core’s NetworkPolicies, EndpointSlices and the Ingresses, the spec and status of the core’s pods and of every pod that isn’t ready, and the logs (with the previous run’s) of every pod inopenfief,cnpg-systemand the clients’ namespace, and of Traefik. A failed installation prints the same before any test runs.imageswaits forcheckand calls images.yml, withpushtrue only on a push tomain.docsruns on a push tomainaftercheck.checkhas built the site intask checkand uploadedsite/publicas a Pages artifact;docspublishes it with GitHub’sdeploy-pagesaction to thegithub-pagesenvironment (docs.md). It needs the repository’s Pages source set to “GitHub Actions”, with the custom domainopenfief.dev. With “Deploy from a branch”, the deploy fails with a 404 and GitHub serves the repository root through Jekyll instead.deploy-demoruns on every push tomain, aftercheck,e2eandimagessucceed. It has no on/off variable: publishing the artifact is harmless without a demo machine, because nothing pulls it. It calls deploy-demo.yml, described in The demo.
Images. The discover job runs task ci:images. It lists each cmd/<name>/Dockerfile and plugins/<name>/Dockerfile as a JSON matrix and lowercases the registry prefix; today that is core, demo-seed, egress-proxy and operator. An empty list skips build rather than failing it. build runs once per image. Buildx builds linux/amd64 and linux/arm64 from the repository root, attaches an SBOM and a full (mode=max) provenance attestation, and caches layers per image. The Dockerfiles copy go.work, go.work.sum and sdk/go and build in workspace mode, as the repository does locally, because the core imports the SDK module, which a private repository can’t serve as a module version. They cross-compile Go on the build machine and copy the binary into a distroless base pinned by digest. When pushing, the job logs in to GHCR with the job’s token, pushes, and runs task image:sign IMAGE=<image>@<digest>, so cosign signs the digest just pushed, never a tag. cosign v3 stores the signature as a Sigstore bundle attached to the digest as an OCI referrer. The operator verifies plugin images in that format and trusts images.yml on main as OpenFief’s own signer, so signing has to stay in that workflow. The tags depend on the caller:
| Caller | Pushed and signed | Tags |
|---|---|---|
| Pull request | no, build only | none |
Push to main | yes | sha-<short sha> and main |
| Release | yes, unless dry_run | the version, such as 2026.11.0-beta.1 |
The demo#
The demo is one Hetzner Cloud server running k3s, holding synthetic data only (GR-12). demo.md is the operator’s guide; it’s a proposal, and this section only explains the machinery. The pipeline touches the demo in two ways.
The machine. demo:plan, demo:up, demo:status, demo:down and demo:recreate all run demo.sh with the pinned hcloud CLI. They run from a terminal, or from demo-infra.yml, a hand-started workflow in the GitHub Environment demo, which holds HCLOUD_TOKEN and CLOUDFLARE_API_TOKEN. The script keeps no state: it finds Hetzner resources by the labels app=openfief and env=<name>, and Cloudflare DNS records by the comment managed-by=openfief-demo env=<name>, so up creates only what is missing and down deletes only what is the demo’s. With the Cloudflare token, up points demo.openfief.dev (an A and a CAA record) at the server, through curl and gojq; without it, up prints the record to create, and DEMO_DOMAIN=none falls back to an sslip.io name. task dns:site (demo.sh dns-site) keeps the website’s records at openfief.dev and www the same way, under its own comment managed-by=openfief-site, and deletes the retired docs. record. When the project has no SSH key labelled openfief-ssh=true, up installs a throwaway public key whose private half is never stored, so Hetzner sets no root password. Before it creates anything, it prices the server and its IPv4 from Hetzner’s price list in integer micro-euros. It refuses if the monthly total of all OpenFief resources would exceed DEMO_BUDGET_EUR, which defaults to 10 EUR. down and recreate need CONFIRM set to the demo’s name. The server boots from cloud-init.yaml, filled in with the Taskfile’s pins. It installs k3s and Flux, checking each download’s sha256, and points Flux at deploy/demo on main.
Deploys. Nothing outside the machine holds a credential for the cluster. Flux follows two kinds of source, declared in stack.yaml. The platform layers follow main in git: the CRDs, the CloudNativePG operator and database, cert-manager and the Let’s Encrypt issuer. OpenFief itself, today the operator, the egress proxy, the core and the demo-seed Job that loads the synthetic demo dataset (demo.md), follows a signed OCI artifact. deploy-demo.yml publishes it by running task deploy:demo TAG=<tag> REPO=<owner/repo>, where the tag is sha-<short commit> for main or the version for a beta:
- pin-images.sh runs
cosign verifyon each image at that tag. It accepts only a signature fromimages.ymlonmain, and writes the verified digest over the all-zeros placeholder indeploy/demo/openfief/kustomization.yaml. One unverified image fails the deploy. - validate.sh checks the result, and kustomize renders it.
flux push artifactpushes the manifests toghcr.io/<owner>/<repo>/demo-manifests:<tag>. cosign signs that digest keylessly, andflux tag artifactmoves thedemotag to it.- Flux on the machine checks the
demotag every two minutes. It applies a new artifact only if the signature comes fromdeploy-demo.ymlonmain.
The deploy job runs in the demo environment with packages: write and id-token: write and no secret. A concurrency group makes deploys queue instead of overlapping.
Releases#
release.yml only starts by hand (workflow_dispatch) and takes three inputs: channel (beta or stable), version, and dry_run, which defaults to true. Only one release runs at a time.
planrefuses a real run unless the actor is the repository owner and the ref ismain. It then runs three tasks on full history.release:checkcalls release-version.sh: stable must beYYYY.M.Nand betaYYYY.M.N-beta.K, with nov, no leading zeros, a month from 1 to 12,Kfrom 1 up, and no existing tag.release:noteswrites the notes to the run’s summary page.release:assetspacksdeploy/intodist/openfief-deploy-<version>.tar.gzand builds the docs site intodist/openfief-docs-<version>.tar.gz; these copies are only a trial and are discarded.imagescalls images.yml with the version as the tag, and pushes and signs only on a real run.publishruns only on a real run.task release:publishrepeats the version check, rebuilds the tarball and notes, and creates the tag at the checked-out commit through the GitHub API. Forstableit also creates a GitHub release with the notes and both tarballs. A beta gets a tag and no GitHub release.deploy-demoruns after every real beta. It publishes the beta’s verified digests as the demo artifact, as above, with the version as the tag.
release-notes.sh finds the previous release with git describe, limited to tags that start with four digits. A stable release skips beta tags, so its notes cover the whole cycle; a beta starts after the newest release of either channel. Each non-merge commit goes into Breaking changes (! or a BREAKING CHANGE: footer), Features (feat), Fixes (fix), Performance (perf) or Other changes, which also takes anything not in Conventional Commit form.
Pins and Renovate#
renovate.json enables four managers: Go modules, npm, GitHub Actions, and a regex manager for # renovate: comments in Taskfile.yml and .github/. rangeStrategy: pin keeps versions exact, and helpers:pinGitHubActionDigestsToSemver pins each action to a commit SHA with the version as a comment. Updates are grouped into one PR each for Actions, kibi, Taskfile tools, CI toolchains and Go modules. Renovate commits as build(deps): ... with a Signed-off-by line, so its PRs pass the DCO check and land under Other changes in the release notes.
| What | Pinned in | Kept current by |
|---|---|---|
| Go toolchain | toolchain go1.27.3 in both go.mod files; golang:1.27.3 by digest in the Dockerfiles | Renovate for go.mod; nothing for the Dockerfiles |
| Go dev tools, including Hugo, hcloud, kustomize and gojq | Taskfile vars | Renovate, “Taskfile tools” |
| kibi, Mermaid, playwright-core | exact versions in package.json, plus package-lock.json | Renovate (npm); kibi as one group |
| flux CLI; k3s and Flux on the demo machine | Taskfile vars, each with sha256 sums | Renovate bumps the version; the sums by hand (demo.md) |
| Demo Helm charts (CloudNativePG, cert-manager) | deploy/demo/*/helmrelease.yaml | nothing |
| Actions | a commit SHA in each uses: | Renovate, “GitHub Actions” |
| Node, k3d, kind | the setup action and ci.yml, with # renovate: comments | Renovate, “CI toolchains” |
| envtest binaries | ENVTEST_K8S_VERSION plus SHA-512 sums in envtest-assets.sh | by hand |
| Protobuf dependencies (googleapis, for the read API’s HTTP rules) | buf.lock, by commit and digest; buf fetches them from the Buf Schema Registry | by hand (no Renovate manager for buf) |
| Sigstore trust root the operator verifies against | internal/plugins/cosign/trusted_root.json | task trustroot:update, by hand: it fetches the root through TUF, and the diff is reviewed |
| Base images | digests in cmd/*/Dockerfile | nothing (see below) |
kubectl, gh, Buildx, QEMU, Chrome (docs diagrams), PostgreSQL (test:pg) | the runner image and the actions’ defaults | not pinned here |
Verification#
The pipeline’s decisions live in shell scripts, and each script that decides something non-trivial has a test in scripts/tests. Each test works in a throwaway directory or git repository from mktemp. The git tests also clear GIT_DIR and its siblings, which a hook exports, so they can’t touch the real repository. Each test is a kibi test record that covers a tooling requirement. task test runs all eleven, so they run in the hook and in CI.
| Test | Proves |
|---|---|
check-docs.test.sh (TEST-tooling-docs-check) | Unchanged docs pass; a reference to an undefined ID, an edited ground-rule line, an ID defined twice and a base revision that isn’t a commit each fail with the right message. |
vulncheck.test.sh (TEST-tooling-vuln-offline) | A high npm finding fails, an unreachable npm registry fails, and a govulncheck that can’t reach vuln.go.dev fails. Stubs keep it offline. |
ci-images.test.sh (TEST-tooling-ci-images) | No Dockerfile gives []; two Dockerfiles give the expected matrix with a lowercased prefix; a duplicate or invalid image name fails. |
plugin-labels.test.sh (TEST-tooling-plugin-labels) | The committed labels pass; a manifest changed without its label fails and names the Dockerfile; a demo Plugin declaring another manifest fails; write brings the label in step, keeps backslashes as they are, and refuses a single quote or a Dockerfile without the label line. |
dco-check.test.sh (TEST-tooling-dco) | A commit signed off by its author passes; a sign-off by someone else and a missing sign-off both fail and name the commit. |
release-version.test.sh (TEST-tooling-release-version) | 14 table cases: valid stable and beta versions pass; a v prefix, leading zeros, month 13, beta.0, the wrong or an unknown channel, and an existing tag fail. |
release-notes.test.sh (TEST-tooling-release-notes) | Stable notes start after the previous stable, beta notes after the previous release of either channel, a first release covers all history, and both ! and BREAKING CHANGE: count as breaking. |
proto-breaking.test.sh (TEST-tooling-proto-breaking) | A base without contracts passes; adding a field passes; changing a field’s type fails and names it; PROTO_BASE and DOCS_BASE both select the base. Needs buf on PATH. |
demo.test.sh (TEST-tooling-demo) | 199 checks against a fake hcloud with synthetic prices, and a fake curl that plays the Cloudflare DNS API on a record file. DNS records are created, updated in place on a new IPv4, left alone on a second run, and deleted only when they carry the demo’s comment; a record set by hand at the demo’s name is refused; without a Cloudflare token the demo falls back to sslip.io. Without a labelled SSH key, only a throwaway Ed25519 public key is uploaded, and down deletes it. demo.sh dns-site creates the apex’s Pages A and AAAA records and the www CNAME, changes nothing on a second run or over matching records made by hand with its comment, reconciles a changed address list exactly, adds the verification TXT only with GITHUB_PAGES_CHALLENGE, deletes the retired managed-by=openfief-docs record, refuses a foreign record at the apex or www, and never touches the demo’s records, nor the demo its records. A missing token is refused before any API call. plan prices the server and IPv4 and creates nothing. up over budget is refused, counting existing resources; otherwise it creates the firewall before the server, with labels, keys and rules, and a second up creates nothing. down needs the matching CONFIRM. The rendered cloud-init pins k3s for the CPU, leaves no placeholder and holds no token. |
demo-pin-images.test.sh (TEST-tooling-demo-pin-images) | With a fake cosign: signed images get their digests, the demo Plugin renders with its pinned plugin image, and comments survive; only images.yml on main is trusted; an unsigned image fails and keeps its placeholder; a malformed tag or digest is refused. |
demo-validate.test.sh (TEST-tooling-demo-validate) | The committed manifests pass; an OCI source without cosign verification, an image pinned by tag only, a Flux path that doesn’t build and an undefined substitution variable each fail. |
To run them and the other self-checks:
task test # Go tests plus the eleven script tests
bash scripts/tests/dco-check.test.sh
PATH="$PWD/.bin:$PATH" bash scripts/tests/demo.test.sh
task lint:ci # actionlint over the workflows
task gen:check # regenerate, then fail on drift
task proto # buf lint, breaking and generated-code checks
task demo:validate # build every demo layer offline; digests and signatures
task demo:plan ENV=demo # read-only Hetzner (and Cloudflare) API calls; needs HCLOUD_TOKEN
task ci:images PREFIX=ghcr.io/arkadiuss/openfief
task release:check CHANNEL=beta VERSION=2026.11.0-beta.1
task release:notes VERSION=2026.11.0
task dco BASE=origin/main HEAD=HEAD
actionlint checks the workflows only statically. To exercise the release workflow without publishing, dispatch a dry run with gh workflow run release.yml -f channel=beta -f version=2026.11.0-beta.1 -f dry_run=true, and read the notes on the run’s summary page. To check what was published, verify the signatures with the identities the pipeline itself trusts, and read an image’s SBOM:
cosign verify ghcr.io/arkadiuss/openfief/operator:main \
--certificate-oidc-issuer https://token.actions.githubusercontent.com \
--certificate-identity https://github.com/arkadiuss/openfief/.github/workflows/images.yml@refs/heads/main
cosign verify ghcr.io/arkadiuss/openfief/demo-manifests:demo \
--certificate-oidc-issuer https://token.actions.githubusercontent.com \
--certificate-identity https://github.com/arkadiuss/openfief/.github/workflows/deploy-demo.yml@refs/heads/main
docker buildx imagetools inspect ghcr.io/arkadiuss/openfief/operator:main --format '{{ json .SBOM }}'
pin-images.sh trusts the first identity and Flux the second. Both assume that Fulcio names the reusable workflow that ran the job, not the workflow that called it; this page hasn’t been checked against a real certificate. On the machine, flux get sources oci and flux get kustomizations show what Flux verified and applied (demo.md).
Nothing tests assert-local-cluster.sh, gen-check.sh, proto-check.sh, envtest-assets.sh or test-pg.sh, a demo machine’s boot, or the tasks only CI runs: image:sign, deploy:demo as a whole and release:publish.
Not built yet#
These are the gaps between sdlc.md or testing.md and the files, plus things that look broken.
- Local green isn’t always CI green (sdlc.md:10). The hook ends with
kibi:staged, while CI checks the whole knowledge base (the last steps ofcheck:commitandcheckin Taskfile.yml). DCO and the PostgreSQL tests run only in CI; thecommit-msghook runs only the docs gate. The hook reads the working tree, so unstaged edits count. Vulnerability databases change between runs. - A failing PostgreSQL test doesn’t stop the demo deploy.
deploy-demoneedscheck,e2eandimages, but nottest-pg(ci.yml:161). - Not everything goes through a task target (sdlc.md:17). Docker’s actions build and push images inside images.yml; only
e2e:imagesbuilds images through a task target, for the local cluster. Third-party actions also create the e2e clusters and publish the docs site. - Not every tool is pinned in the Taskfile (sdlc.md:18). Node, k3d and kind are pinned in the workflows, the demo’s Helm charts in
deploy/demo, and the e2e client image (curl) by digest intest/e2e/testdata. The k3d cluster’s k3s image is named by tag fromK3S_VERSION, not by digest, and CloudNativePG’s manifest, pinned by sha256, names its operator image by tag. kubectl, gh, Buildx, QEMU, the Chrome that renders docs diagrams and the PostgreSQL thattest:pgstarts come from the runner image or the actions’ defaults. task vulnignores low and moderate npm findings (sdlc.md:19 says any finding). vulncheck.sh:24 passes--audit-level=high, and the test covers only a high finding.- Conventional Commits aren’t enforced (sdlc.md:25). Nothing lints commit messages or PR titles, and a non-conforming commit lands under Other changes.
- No PR template asks which requirements a PR implements or how it was verified (sdlc.md:26).
- End-to-end tests cover the demo stack only (testing.md, sdlc.md:11 and :31). The suite runs no plugin, no real Ghostfolio and no fixture server, and goes through no bootstrap install other than the demo’s manifests. The demo loader fetches NBP rates from api.nbp.pl when the runner reaches it and falls back to its committed rates otherwise, so the suite isn’t hermetic. kind has no Traefik, so the route through Traefik is checked on k3d only. The local-cluster guard has no test, though REQ-tooling-e2e-local-only has a scenario.
- The demo serves no product yet (sdlc.md:12 and :32). The deploy path is built, but whether it runs depends on settings outside the repository: the
demoenvironment and public GHCR packages. The app layer holds the operator and the egress proxy, anddemo-routesstays suspended until a core service exists (deploy/demo/stack.yaml:107). The demo database stays empty too: deploy/demo-data/kustomization.yaml:2 saysdeploy/demoincludes the seed Job, but nothing underdeploy/demorefers to it, anddeploy/demo/openfiefdoesn’t pin thedemo-seedimage. - Releases don’t pin their images (sdlc.md:41, D-17). images.yml exposes no digest output. The stable tarball ships
deploy/with bases that name images without a tag or digest (deploy/operator/deployment.yaml:28, deploy/egress-proxy/deployment.yaml:26) and expect an overlay to pin them. Only the demo has such an overlay, and CI fills it in at deploy time. “The CRDs and the bootstrap install” are one archive ofdeploy/. - The dry run is per run, not a gate (sdlc.md:42). A run with
dry_run: falsevalidates inplan, then pushes and signs images straight away (release.yml:73-83). Nothing requires an earlier dry run of the same commit, or checks that the commit passed CI. Because image builds don’t fail fast, one image can be pushed while another fails, which leaves pushed images with no tag. - No SDK or contract releases (sdlc.md:43). There’s no
sdk/go/vX.Y.Ztagging and no TypeScript SDK.buf breakingnow has contracts to check (the connector contract and the read API) and runs on every change, not only before an SDK release. - Renovate misses some pins (sdlc.md:47).
enabledManagers(renovate.json:5) lacksdockerfile, yet cmd/operator/Dockerfile:3, cmd/egress-proxy/Dockerfile:3 and cmd/demo-seed/Dockerfile:5 say Renovate updates them, sogolang:1.27.3can drift fromgo.mod’s toolchain. No manager reads the demo’s Helm chart versions. For k3s and Flux, Renovate bumps the version but not the sha256 sums beside it: a stale flux CLI sum fails the next deploy job, while stale k3s or Flux install sums show up only when a machine boots and stops at that download. - Smaller issues.
task testruns proto-breaking.test.sh, but itsdepsinstall envtest, kustomize and gojq, notbuf, so it fails on a clone whose.bin/predates buf;task checkhides this becauseprotoruns first. The setup action’s “no go.mod yet” step is dead now thatgo.modexists (action.yml:22-28). cmd/demo-seed/Dockerfile:3-4 still says that go.mod alone builds the image, though every Dockerfile now builds the Go workspace withsdk/go.proto:gen’s description says “no remote BSR”, which holds for the plugins, but buf now fetches googleapis from the Buf Schema Registry, so the proto steps need the network until buf has cached it.