29 KiB
Project notes for Claude
Layout (v4)
cmd/x509-certificate-exporter/— binary entrypointpkg/cert/— public API for certificate parsing (pem,pkcs12subpackages)pkg/registry/— public Prometheus collector + label registrypkg/fileglob/— public glob/walk engine (EXPERIMENTAL — promoted in v4 RC)pkg/source/{file,k8s,kubeconfig}/— public Source implementations (EXPERIMENTAL)internal/— wiring & process-lifecycle: config, log, server, productchart/— Helm chart
The v3 code was deleted; v4 is at the repo root. The Go module path uses /v4
(github.com/enix/x509-certificate-exporter/v4/...).
Build & test workflow
Three tools share the workload, all wrapped by Taskfile.yml. Prefer
task targets when working interactively; task --list enumerates
everything.
- Dagger Module (
dagger.jsonat repo root, source files indagger/) — sandboxed QA/CI pipelines: lint (Go/Helm/Renovate/ Markdown), unit tests, security scans, helm chart docs. Each exported method on theX509Cestruct is a Dagger function, called viadagger call <function>(find-up locatesdagger.jsonfrom any cwd in the repo). TheNewconstructor takes the working tree viadefaultPath="/", so no--sourceflag is needed. - GoReleaser (
.goreleaser.yaml) — every container image in the repo, dev OR release. Release pipeline: cross-compile binaries × OS/ arch, archives, checksums, multi-arch container images for the scratch (default) + busybox (alt) variants, push to ghcr/quay/docker.io, cosign keyless on everything, GitHub Release (CI viarelease.yaml). Local:task build:image:host(host-arch, fast iteration),task build:image:all(every cross-arch variant). Dev: Tilt'scustom_buildcallsgoreleaserdirectly withGORELEASER_TILT=1, gating a dedicated dockers_v2 entry that usesbuild/Dockerfile.busybox(the alt release variant — chosen for dev because it ships a shell forkubectl execdebugging, not because it's the project default). - Direct CLI for things that don't need a sandbox: k3d/tilt/helm
on the dev cluster, Renovate dry-run via Docker, and
go mod tidy/go get -u(pure toolchain operations — Dagger overhead would buy us nothing becauseGOTOOLCHAIN=automakes host execution bit-identical anyway). GitHub Action SHA-pinning is owned by Renovate (pinDigests: trueinrenovate.json5).
| Goal | Command | Notes |
|---|---|---|
| Local binary | task build:binary:host |
goreleaser build --single-target --snapshot --clean — host-arch binary under dist/x509ce_<os>_<arch>_<v>/x509-certificate-exporter, with a stable relative symlink at dist/x509-certificate-exporter (same flags / ldflags / version stamping as the release pipeline) |
| Snapshot host-arch only | task build:image:host |
goreleaser release --snapshot --skip=publish,sign with GORELEASER_LOCAL_PLATFORM=1 — fast iteration, no QEMU cross-build |
| Snapshot all images | task build:image:all |
Like task build:image:host but every cross-arch variant — validates the full release matrix without pushing |
| Lint Go | task lint:go |
dagger call lint-go — full golangci-lint set |
| Lint Helm | task lint:helm |
dagger call lint-helm |
| Lint Markdown | task lint:markdown |
dagger call lint-markdown |
| Lint all | task lint |
Go + Helm + Renovate + Markdown |
| All tests | task test |
runs test:unit + test:fuzz + test:helm-examples + test:helm-fixtures + test:helm-render + test:e2e sequentially |
| Unit tests | task test:unit |
dagger call test — gotestsum + -race + coverage |
| Fuzz smoke | task test:fuzz |
each Fuzz* target run for 5s — catches seed-corpus regressions |
| Helm examples | task test:helm-examples |
dagger call test-helm-examples — helm lint chart --values on every docs/examples/**/*.values.yaml |
| Helm schema fixtures | task test:helm-fixtures |
dagger call test-helm-fixtures — regression net for chart/values.schema.json (positive + paired .expect.txt negatives under test/schema/{valid,invalid}/) |
| Helm render alignment | task test:helm-render |
dagger call test-helm-render — helm template against test/render/all-watch-modes.yaml and assert every ConfigMap scan path is reachable from a DaemonSet volumeMount (catches configmap↔daemonset drift) |
| End-to-end tests | task test:e2e |
throwaway k3d cluster, Helm install, scrape /metrics |
| Secret leak scan | task security:gitleaks |
dagger call gitleaks — open-source gitleaks CLI on the working tree; no license/token, so fork PRs scan identically |
| Vuln scan | task security:govulncheck |
dagger call govulncheck |
| Vuln scan (deps) | task security:vuln-deps |
dagger call trivy --scan-type=fs |
| Chart misconfig | task security:chart-misconfig |
dagger call trivy --scan-type=config --scan-ref=chart |
| Tidy go.mod | task go:tidy |
go mod tidy on main + dagger/ (direct, no Dagger) |
| Bump Go deps | task go:upgrade |
go get -u ./... + tidy on the main module only — the dagger module's SDK is tied to dagger.json engineVersion |
| Realign dagger module | task dagger:develop |
dagger develop + tidy — only needed if you edit dagger.json by hand; CI does it for Renovate's bumps |
| Renovate dry-run | task renovate:plan |
extracts deps + lists planned bumps without modifying files (debug renovate.json5) |
| Renovate apply | task renovate:patch |
applies the same bumps to the working tree, format-preserving, best-effort (skips ambiguous cases) |
| Render chart README | task doc:helm |
dagger call helm-docs export --path=chart/README.md |
| Visualize package deps | task analysis:graph |
goda graph ./... → dot -Tsvg → xdg-open (writes to dist/graph.svg) |
| Inspect binary size | task analysis:size |
builds the host-arch binary then opens gsa --tui on it |
Dagger module architecture
A standard Dagger Module: dagger.json at the repo root with
source: "dagger" pointing at the source directory. The Dagger CLI
locates the module via find-up, so dagger call <function> works
from anywhere in the repo without -m.
Layout:
dagger.json(repo root) — module manifest (name, SDK, source dir, engine version).dagger/main.go—X509Cestruct +Newconstructor.New'ssource *dagger.Directoryparameter has+defaultPath="/"(resolves to the module root = repo root) and a+ignore=list for cache-key hygiene (excludesdist/,.git/, etc.).dagger/base.go— pinned image versions (Renovate-tracked via the regex manager inrenovate.json5) and thegoBasehelper that prepares a Go container with go.mod/sum prefetched and cache volumes mounted. Helpers are package-private (lowercase) so they're not exposed as Dagger functions.dagger/lint.go—LintGo,LintHelm,LintRenovate,LintMarkdown. golangci-lint is compiled from source against the project's Go toolchain (GOTOOLCHAIN=auto) — official prebuilt images embed go/parser+go/types of whatever Go they were built with.dagger/test.go—Testruns gotestsum with-race+ coverage. Addsgcc+musl-devto the alpine container because-racerequires CGO, which on Alpine pulls those.dagger/security.go—Govulncheck(@latest— accepting the small drift in exchange for not having to track a separate version),Trivy(one parameterized function for both scan families:--scan-type=fsfor Go deps + lockfiles + OS packages;--scan-type=configwith--scan-ref=chartfor IaC misconfig; Trivy DB cached via a Dagger CacheVolume so successive runs skip the ~50 MB download), andGitleaks(open-source gitleaks CLI,gitleaks diron the tree). Gitleaks runs the binary rather than gitleaks-action because the action needs a paidGITLEAKS_LICENSE+ a write token to comment, and GitHub withholds both from forkpull_requestworkflows — so the action failed on external PRs. Itssourceparam carries its own+ignore(dropsdist//node_modules//.git/but keepsdagger/) rather than reusing the module-wide filter, so hand-written code stays in the secret scan.dagger/helm.go—HelmDocsruns jnorwood/helm-docs and returns a*dagger.File. The Taskfile chains... export --path=chart/README.mdto materialize it back to the working tree.dagger/dagger.gen.go,dagger/internal/— generated bindings, refreshed bydagger develop. Gitignored (seedagger/.gitignore).
Dev environment
End-to-end dev loop driven by Tilt + k3d + Dagger:
task dev:cluster:up # one-shot: k3d cluster + local registry on :5000
task dev:up # tilt up: watches sources, rebuilds via Dagger, redeploys
task dev:down # tilt down + destroy cluster, registry, kubeconfig
task test:e2e # build/deploy/seed and run ./test/e2e against /metrics
Layout:
Taskfile.ymldev:cluster:up/dev:cluster:downtasks — k3d bootstrap, fully inlined (no shell wrappers). Idempotent viaif ! k3d ... getchecks. The cluster is wired to the local registry via--registry-useso nodes pull fromk3d-x509ce-dev-registry:5000natively. Traefik + ServiceLB are disabled to keep the cluster minimal. The kubeconfig is written tokubeconfig.yamlat the repo root (gitignored) and~/.kube/configis left untouched.dev/scenarios/— single source of truth for fixtures: cert/keypair/chain helpers + a list ofScenariovalues describing each Kubernetes object the cluster should hold (lifecycle, key algo, format, expected metric series). Both the seed and the e2e test import this package.dev/seed/main.go— appliesscenarios.All()to the cluster (idempotent upsert of namespaces, Secrets and ConfigMaps viaclient-go). Never edit YAML manifests for test data — extend the scenarios list instead.dev/values.yaml— Helm values shared by Tilt andtask test:e2e. Defines everysecretTypesrule (PEM viakubernetes.io/tls, PEM viaOpaque, PKCS#12 withpassphraseKey, passwordless PKCS#12 viatryEmptyPassphrase), enables ConfigMap watching, exposes a couple of Secret labels, and excludes the negative-test namespace by label.Taskfile.ymltest:e2etask — pure-Taskfile pipeline (no shell wrapper). Stands up a fully isolated, throwaway k3d cluster + registry whose names are suffixed with a randomRUN_ID(x509ce-e2e-<hex>andx509ce-e2e-registry-<hex>), and asks the kernel for a free registry port viasocket.bind(0)— so multipletask test:e2einvocations can run in parallel without colliding on docker container names or host ports. Generates an ephemeral KUBECONFIG viamktemp, exports the cluster/registry coordinates asE2E_CLUSTER_NAME/E2E_REGISTRY_NAME/E2E_REGISTRY_PORT, then hands off totilt -f test/e2e/Tiltfile ci. Teardown is registered as adefer:command at the top ofcmds:and runs unconditionally — success, failure, or Ctrl-C — covering cluster delete, registry delete, and KUBECONFIG removal. No pre-flight cleanup pass: stale state from a previous run cannot share names with this one. Completely independent from the dev cluster.test/e2e/Tiltfile— drives everything that runs inside the e2e cluster: Dagger image build + push to local e2e registry →helm_resource()withdev/values.yaml+test/e2e/values.yamloverrides → seed →local_resource("e2e-test")runninggo test -tags=e2e. Run viatilt ci, exits non-zero iff any resource fails.test/e2e/values.yaml— e2e overrides on top ofdev/values.yaml. Disables the chart's ServiceMonitor + PrometheusRule (no prom-operator CRDs needed; the test scrapes/metricsdirectly).test/e2e/e2e_test.go— gated behind thee2ebuild tag. Scrapes/metricsfrom the running exporter, parses withexpfmt.TextParser, asserts everyscenarios.All()entry has the expectedx509_cert_not_after/x509_cert_expiredseries and that the negative scenarios are absent / produce the rightx509_source_errors_totalreason.Tiltfile— dev loop only. Orchestrates: ensure-cluster → install kube-prometheus-stack (operator + lightweight Prometheus, all optional sub-charts disabled) →custom_build()calling Dagger →helm_resource()deploy of the exporter (--values ./dev/values.yaml) → seed. Distinct cluster (x509ce-dev) and registry (port 5000), so atask test:e2erun can happen in parallel without clashing on cluster state.
GoReleaser + Tilt hookup: custom_build() watches cmd/, pkg/,
internal/, go.mod, go.sum, build/Dockerfile.busybox, .goreleaser.yaml.
On any change Tilt invokes goreleaser release --snapshot ... with
GORELEASER_TILT=1, which builds a single host-arch image from
build/Dockerfile.busybox (the alt release variant — kept for dev
because it ships a shell, even though scratch is the project default)
and loads it into the local Docker daemon. A hooks.post on the
tilt dockers_v2 entry strips the -<arch> suffix that dockers_v2
appends in snapshot mode so Tilt finds the image at exactly
EXPECTED_REF. Tilt then pushes to the local registry; Helm is
reconfigured with the new tag and the pod restarts.
Forwarded ports:
localhost:9793— exporter metricslocalhost:9090— Prometheus UI (already configured to scrape the exporter via the chart's ServiceMonitor; rules fromprometheusRules.create=trueare also picked up because Prometheus has no selector filter)
The exporter chart's prometheusServiceMonitor.create and prometheusRules.create
default to true, so no extra config is needed for Prometheus to scrape and
alert on the dev cluster.
Note on build times: Rebuilding and redeploying the x509-certificate-exporter
Tilt resource takes about a minute. Keep this delay in mind when checking
the pod's state or making HTTP requests to it right after modifying source files.
Coding conventions
- Public API lives in
pkg/, internals ininternal/. Don't promote a package frominternal/without a real consumer. - Tests use the standard
*_test.gocolocated layout. Prefer table-driven tests for the parser/registry packages. - Parsers that consume untrusted bytes (PEM, PKCS#12, fileglob patterns) carry
Fuzz*targets in*_fuzz_test.gofiles.task test:fuzzruns each for 5s (smoke); for a real session usego test -fuzz=<name> -fuzztime=10m ./<pkg>. A crash gets persisted undertestdata/fuzz/...and becomes a regression test — commit those files alongside the fix. - Version metadata is injected at build time via
-ldflags -Xintointernal/product. Don't read it from env or from disk at runtime. - The
formatVersionshort form isMAJOR.MINOR.PATCH+gSHORTSHAand gets a.dirtysuffix when the working tree is dirty.
Chart conventions
Whenever you touch chart/values.yaml, chart/templates/**, or
anything else that influences the chart's rendered output, walk
through this checklist before considering the work done — the test
infrastructure exists to catch the cases you forget, but the
discipline minimises that load. Think hard about each item; the
generated artefacts and the schema fixtures are intentionally a
ratchet that cannot be loosened without explicit human review.
- Regenerate the doc artefacts:
task doc:helmrebuilds bothchart/README.md(helm-docs fromchart/README.md.gotmpl+# --docstrings) andchart/values.schema.json(helm-schema from# @schemaannotations). Commit both alongside your change. Thechart-readme.yamlCI workflow enforces lockstep. - Audit the schema annotations on
chart/values.yaml. Every new value field deserves a# @schemablock. Convention:- Strict on chart-defined params: enums (
pullPolicy,severity,format, …),minimum/maximumranges (ports, replicas, retention windows),oneOfmutex (e.g.secretTypesitems),additionalProperties: falseon closed structures. - Permissive (
additionalProperties: true; properties: {}) on K8s pass-through fields where the user must be free to set anything the K8s API admits —resources, probes,securityContext,nodeSelector,affinity,tolerations[].items, etc.
- Strict on chart-defined params: enums (
- Add fixtures under
test/schema/{valid,invalid}/:valid/<name>.yamlfor any new constraint that should accept a class of legitimate user input (lock down "this works").invalid/<name>.yamlpaired with<name>.expect.txtlisting JSON-path-anchored substrings (e.g.at '/foo/bar') that helm lint must surface in the rejection. Anchor on the path, not the wording — helm's exact error string is less stable than the path.- Run
task test:helm-fixturesto validate.
- Verify
task test:helm-examplesstill passes — every file underdocs/examples/**must continue to validate against the updated schema. If it doesn't, you either broke a documented path (regression — fix the schema or the chart) or the example was wrong all along (legitimate find — fix the example, mention it in the commit message).
Treat the schema + fixtures as a regression net. A future intentional loosening of a constraint surfaces as a fixture failure that the reviewer must explicitly acknowledge by deleting / weakening the fixture. That's the right level of friction.
Go version: single source of truth
go.mod's go X.Y.Z directive is the only place the Go version is pinned.
Every other reference is intentionally loose:
dagger/base.go'sgolangImageconstant (golang:X.Y.Z-alpine) is a bootstrap image; Renovate keeps it close togo.modfor cache hits, but if it drifts, Go'sGOTOOLCHAIN=auto(set in thegoBasehelper) downloads the exact toolchain declared ingo.modon demand.build/Dockerfile.seed-hostpath'sFROM golang:X.Y.Z-alpineis the same deal (it compiles the e2e seed binary). It rides in the same Renovate PR as the two above — not because it's listed anywhere, but because the image is literally namedgolang, which thegolang toolchaingroup matches on packageName.flake.nixuses unpinnedpkgs.gofor the same reason — the dev shell runs whatever Go nixpkgs ships, andGOTOOLCHAIN=autohandles the rest.- The CI workflows use
actions/setup-go@v5withgo-version-file: go.mod, so the runner automatically picks up whatever versiongo.moddeclares. No separateVERSION_GOLANGenv to track.
To bump Go: change go.mod's go directive (or let Renovate do it).
Everything else either auto-updates or auto-resolves at build time.
Dagger version: single source of truth
dagger.json's engineVersion is the only place the Dagger version is
written. A module refuses to run on a CLI older than that value, so every
consumer reads it instead of repeating it:
dagger/go.mod'sdagger.io/daggerSDK — bumped by Renovate in the same PR (thedagger SDK + enginegroup), then realigned bydagger develop, which also settles the transitive deps the generated bindings pull in (e.g.querybuilder, whose import path moves between SDK versions).- The dev shell —
flake.nix'sshellHookrunsscripts/dagger-cli.sh, which readsengineVersion, fetches that exact CLI once into${XDG_CACHE_HOME}/x509-certificate-exporter/, and verifies it against the release'schecksums.txtbefore use. Cache hit is atest -x, so the shell stays instant and works offline once warm. - CI —
.github/actions/dagger(a local composite action) readsengineVersionand passes it todagger/dagger-for-github. That action's SHA pin lives in that one file; workflows just sayuses: ./.github/actions/dagger+args:.
Deliberate non-choices, so nobody "fixes" them back:
- The CLI is not a Nix package. Neither the
github:dagger/nixinput (a separate release train that lags core, so it silently drifts belowengineVersion) nor a localfetchurl(version + four per-arch hashes to hand-maintain). Both store a second copy of the version that nothing can derive from the manifest — precisely what drifts and breaks everydagger call. Fetching the binary costs nothing in reproducibility terms anyway: Dagger pulls its engine as an OCI image by tag at run time, in CI and locally. - No version pinned in workflows. 15 copies of
version:is 15 chances to drift.
To bump Dagger: let the Renovate dagger SDK + engine PR land. The
Dagger module workflow commits the regenerated dagger/go.mod onto
that PR branch, shell and CI follow from engineVersion, and there is
nothing to do by hand. If you edit dagger.json yourself, run
task dagger:develop.
Release pipeline
Releases are tag-driven. A maintainer pushes a vX.Y.Z tag manually
(or via automation) and the release.yaml
workflow fires. GoReleaser drives all the heavy lifting: changelog generation,
binary cross-compilation, container images, chart packaging, signing, and
creating a draft GitHub Release. The maintainer reviews the draft and
publishes it manually.
Changelog sections in GoReleaser's changelog.groups (.goreleaser.yaml)
mirror the Conventional Commit type mapping:
Security Updates (security:), Features (feat:), Bug Fixes (fix:),
Performance (perf:), Dependencies (deps:), Documentation (docs:).
Types ci:, chore:, refactor:, test:, build: are excluded from the
public changelog.
Build & publish — release.yaml
Triggered by a v* tag push. Two jobs, all driven by
.goreleaser.yaml for the heavy lifting:
goreleaser(envrelease, gated by required reviewers) — builds binaries × 6 OS / 4 archs (with exclusions for non-existent combos), packages as.tar.gz/.zip, computes checksums, signs the checksums file with cosign keyless. Builds two multi-arch container images (scratch is the default, busybox is the alt with shell; each spans amd64/arm64/riscv64) via GoReleaser'sdockers_v2— onedocker buildx build --push --platform=...per variant, hitting ghcr/quay/docker.io in one go. Each pushed multi-arch image is signed with cosign keyless (docker_signs→artifacts: images). Then a post-loop readsdist/artifacts.json, resolves each pushed image tag → digest, runs syft for an image SBOM, and attaches it as a cosign attestation (predicateTypecyclonedx). The Dockerfiles useARG TARGETPLATFORM+COPY $TARGETPLATFORM/<binary>per the dockers_v2 build-context layout. The same job emits a SLSA Build Level 3 provenance attestation overdist/checksums.txtviaactions/attest-build-provenance, uploaded to GitHub's native Attestations API and signed via Sigstore. Verified by consumers withgh attestation verify <archive> --owner enix.chart(envrelease) — packages the Helm chart withhelm package(overridingname:/version:/appVersion:in-runner from the env vars + tag, so the chart on disk stays generic), pushes as an OCI artifact, signs with cosign keyless. Waits ongoreleaserso the chart's image references point to images that actually exist in the registries.
GoReleaser refuses to release on a dirty working tree, which gives a free reproducibility guarantee.
The goreleaser and chart jobs both run in the release GitHub
Environment. Three vars.* MUST be set on that Environment (no
fallback — the workflow validates and fails fast):
IMAGE_NAME— container image name (e.g.x509-certificate-exporter)CHART_NAME— Helm chart name (often ==IMAGE_NAME)CHART_REGISTRY— OCI host/namespace where the chart is pushed, WITHOUT theoci://scheme (e.g.quay.io/enix/charts). The workflow prependsoci://only where helm needs it; cosign and the verification commands consume the bare form directly.
The container image registries (ghcr.io/enix, quay.io/enix,
docker.io/enix) are hardcoded in .goreleaser.yaml rather than
sourced from a variable. A fork that needs different namespaces edits
that file directly.
The Go binary is hardcoded as x509-certificate-exporter in
.goreleaser.yaml's binary: field (and in the archive
name_template). Release archives are therefore named uniformly
across forks — only the image and chart identities change via
the vars.* overrides. Consumers downloading binaries always
extract a binary called x509-certificate-exporter and can script
against that name.
Registry credentials live on the release Environment:
- Image registries:
QUAY_USERNAME/QUAY_TOKEN,DOCKERHUB_USERNAME/DOCKERHUB_TOKEN. GHCR uses the runner'sGITHUB_TOKEN. - Chart registry:
CHART_REGISTRY_USERNAME/CHART_REGISTRY_TOKEN— intentionally provider-agnostic (noQUAY_*re-use) so the chart can be hosted independently of the image registries (e.g. quay.io for images and ghcr.io for the chart, or vice-versa).
Verification commands for downstream consumers are documented in the hardening guide.
Renovate
Self-hosted on GitHub Actions (no Mend app installed). The
renovate workflow invokes the
Renovate CLI on a weekly cron + workflow_dispatch + on every push that
modifies renovate.json5. Auth is via a GitHub App
(RENOVATE_APP_CLIENT_ID + RENOVATE_APP_PRIVATE_KEY secrets) so
PRs/commits come from the bot identity and downstream workflows trigger
on them.
renovate.json5 deliberately has no top-level schedule: — the
workflow's cron is the single source of timing truth. Adding a schedule
to the config would silently neuter manual workflow_dispatch runs
outside the configured window.
For local config debugging, task renovate:plan runs the CLI in
--platform=local mode with the default dryRun=lookup: extract +
lookup phases only, nothing written to disk. Use it to verify that
managers catch what you expect, inspect skipReasons, and see how
groupings/branches resolve. Actual bumps still come from the Actions
workflow.
task renovate:patch is the same dry-run, but its JSON debug output
is piped to scripts/renovate-patch.py, which finds the
packageFiles with updates event and applies each dep's first update
in place — replacing the exact replaceString Renovate would have
edited, preserving formatting and comments byte-for-byte. Best-effort:
any ambiguity (replaceString missing/non-unique, pinDigest on a
previously unpinned dep, rollback updates, etc.) is SKIPPED with a
diagnostic on stderr. The intent is to leave the working tree in a
state Renovate's own delta logic can pick up cleanly on its next
scheduled run.
Both renovate:plan and renovate:patch template the same image from
the Taskfile var RENOVATE_IMAGE. A regex manager in renovate.json5
tracks that one declaration, so the Taskfile auto-bumps in lockstep
with renovateImage in dagger/base.go — no manual sync required.
Config validated via task lint:renovate (runs renovate-config-validator
inside the official Renovate image, sandboxed by Dagger). Highlights:
build/Dockerfile.scratch(default variant) andbuild/Dockerfile.busybox(alt variant with shell)FROMlines tracked natively by the dockerfile manager, withpinDigests: true(via packageRule withmatchCategories: ["docker"]).- Custom regex managers for the K3s image in
Taskfile.yml(K3S_IMAGE, shared by dev + e2e clusters), the kube-prometheus-stack version inTiltfile(KUBE_PROMETHEUS_VERSION), the container images pinned as Go constants indagger/base.go(golangImage,alpineImage,helmImage,renovateImage,helmDocsImage), and thegolangciLint/gotestsumModuleGo install versions in the same file. - Go-toolchain bumps land in a single PR via
groupName: "golang toolchain":go.mod+dagger/go.mod(packageNamego), plusdagger/base.go'sgolangImageandbuild/Dockerfile.seed-hostpath'sFROM golang:(packageNamegolang). CI workflows pick up the Go version fromgo.moddirectly viasetup-go'sgo-version-file. - Version lockstep groups. When one tool's version is written in two
places, the two managers usually mint different package names — so
they land in separate PRs and drift unless a packageRule lists both
under one
groupName. Match on packageName (matchPackageNames); its depName fallback is deprecated and will be removed. In place today:dagger(dagger.jsonengineVersion + thedagger.io/daggerSDK),helm(helmImageindagger/base.go, which lints/tests the chart,setup-helminrelease.yaml, which packages and publishes it — packageNamesalpine/helmvshelm/helm), andrenovate(renovateImage+ the Taskfile'sRENOVATE_IMAGE— both resolve to packageNamerenovate/renovate). Adding a second write-site for any tool means adding it to its group.
flake.lockand transitivego.sumentries refreshed on every Renovate run vialockFileMaintenance.