6.0 KiB
@hanzo/detect
Derive a repo's CI/CD config from the repo. Zero runtime dependencies.
A Go module with a Dockerfile does not need a human to write down "build this Go
module with this Dockerfile" — the platform can see that. This package turns a
repo tree into the same PlatformConfig that platform.hanzo.ai already
consumes, so a repo declares config only for the things its contents genuinely
do not state.
import { detect, withCluster, fsTree } from "@hanzo/detect";
const ctx = { org: "hanzoai", repo: "world" };
const result = withCluster(detect(fsTree("/path/to/world"), ctx), clusterIndex, ctx);
// result.config.builds[0].image → "ghcr.io/hanzoai/world"
// result.config.deploy.target → App hanzo/world on cluster-a
Two passes
| reads | answers | |
|---|---|---|
detect(tree, ctx) |
the repo only | which images, from which Dockerfile, in which context, pushed where |
withCluster(result, index, ctx) |
operator CRs + Deployments | corrects image names, resolves deploy: |
Both are pure. detect alone yields a complete, buildable config; the cluster
pass only ever adds evidence. src/fs-tree.ts is the single module that touches
a disk — every rule is tested from an in-memory tree.
Why the cluster is an input
Where an image gets rolled out is not a property of the source, but it is not
unknowable either: the operator CRs already say it. deploy: is therefore a
join — image ref → the workload already running that image — not config.
The hanzo.yml that spelled it out was a copy of the cluster that could go
stale, and three in the fleet already have (see below).
The override contract
Derived config is the default. A hanzo.yml exists only to state what the tree
and the cluster cannot, and it should be small. One rule, at two levels:
SET — if the file declares images at all (images: or build:), that list
is the set of images to build. Declaring no images means "build what you
derived". This is the only way to remove a derived image, and it needs no new
key: hanzoai/cloud already uses exactly this shape by hand to keep its main
image out of the platform pipeline (release.yml owns that one).
FIELD — inside a build the repo did declare, and for deploy: / e2e: /
publish:, the declared value wins outright.
A repo with zero config files gets: every production image it contains, the
right ghcr.io/<org> ref, and a deploy target if the fleet runs that image.
Nothing else is needed.
# The whole file, for a repo that only needs to correct one thing.
e2e:
spec: tests/16-pricing.spec.ts
baseDomain: pricing.hanzo.ai
irreducible(derived, declared) reports what a file adds over detection — run
it over a repo to find out whether its config is still pulling its weight.
Caveat, stated plainly
Platform's parser fills its own defaults (context: ".", a tag pattern) before
mergeConfig sees a declared build, so a declared build cannot distinguish
"omitted" from "explicitly the default". Derived values therefore fill only
builds the repo did not declare. This is why the recommended first wiring is
fallback-only — it changes nothing for a repo that already has a file.
Wiring into platform
scheduleBuilds returns null when a repo has no config, i.e. "opted out".
Zero-config CI is that null becoming a derived config. In
pkg/platform/src/services/ci/build-scheduler.ts, resolveSource currently
yields { config } straight from fetchPlatformConfig; the change is to fall
back when it is null:
// after: const { organizationId, config } = await resolveSource(...)
const effective = config ?? detectFromRepo(input.repo, input.sha);
if (!effective) return null; // genuinely nothing to build
Phase 1 is fallback-only: repos that already have a hanzo.yml are untouched,
and the 400+ repos with none gain CI. Phase 2 swaps the ?? for
mergeConfig(derived, config) and the existing files shrink to their
irreducible parts.
The detector needs a tree, so detectFromRepo reads the repo at the SHA through
the same Hanzo Git client fetchPlatformConfig already uses — implement
RepoTree over its contents endpoint (about 20 lines; fsTree is the reference).
What it cannot derive
Measured over every repo in the fleet that has a hand-written config
(node bin/audit.ts), these are the only things a file still has to say:
- An image the fleet does not run.
console-embedandcloud-flagsare build-time artifacts another repo's Dockerfile consumes viaFROM; nothing deploys them, so there is no evidence they exist.commerce-storeis declared but not deployed anywhere. - A deliberate exclusion.
hanzoai/cloudandhanzoai/hanzo.aiboth contain a root Dockerfile they intentionally do not build here. - A name unrelated to its source.
otel-collectorbuildscmd/o11yotelcollector/Dockerfile.selfbuildasghcr.io/hanzoai/otel-collectorand calls itcollector. No rule recovers that; it is a choice. - Which e2e spec guards a service.
pricingnames one of universe's specs.
Everything else — Dockerfile selection, image name, registry, build context, matrix, tag shape, deploy cluster/namespace/kind/name, publish targets — is derived.
Tests
npm test # 41 tests: rules on fixtures + real fleet trees
npm run typecheck # strict, noUncheckedIndexedAccess
npm run typecheck:seam # our PlatformConfig ↔ platform's, both directions
test/fixtures/trees.json is a real snapshot of 17 fleet repos (regenerate with
bin/snapshot-trees.ts; the diff is the review). test/fixtures/cluster-index.json
is a synthetic operator-CR index in the shape bin/cluster-index.ts emits,
holding only the workloads those trees join against plus the decoys the ranking
must reject.
Tools
bin/cluster-index.ts |
build a ClusterIndex from live clusters (read-only kubectl get) |
bin/snapshot-trees.ts |
freeze real repo trees into a test fixture |
bin/audit.ts |
per-repo derived-vs-declared diff |
bin/sweep.ts |
fleet-wide counts: how many repos need any config at all |