Read a rebuild of an unchanged source as no move, whatever image digest it made (hq issue 280)
mesh/merge-gate error: the check could not run: a throwaway postgres:17-alpine could not be raised: docker run --label mesh.build=build-1791317509716888018…
mesh/delivery delivered

An image is not byte-reproducible, so ADR 0236's 'same artifacts is no move'
never held for one: a catalogue merge that did not touch the bus rebuilt it,
and every send to the control node waited for a planned bus upgrade.

The builder now records a source fingerprint per build (module tree, context
trees, bases and toolchains by digest). A rebuild with the fingerprint of the
build it repeats is registered with that build's artifacts, handed to modules
standing on it, holds no push, demands no bus step, and a plan sends and
gates nothing for it. Identical artifacts remain a second way to be no move.
This commit is contained in:
2026-10-06 22:09:11 +02:00
parent b9e0cd34c3
commit 792352dfad
15 changed files with 773 additions and 35 deletions
+43 -3
View File
@@ -67,6 +67,12 @@ type Result struct {
// mesh keeps carries no build section, so nothing else could say that a merge there is a
// change to this module (novox/hq 04-ISSUES/131).
Read []catalogue.ArtifactContext
// Source is the build's source fingerprint (source.go): what it was made from — the module's tree,
// the contexts' trees, the bases and toolchains by digest — hashed. Empty where the source does not
// pin the build. Two builds with one fingerprint are one build, whatever digests they made
// (novox/hq issue 280).
Source string
}
// GitCredential is the forge credential a clone may present when the server asks for one.
@@ -161,6 +167,12 @@ func Build(ctx context.Context, run Runner, publish Publisher,
}
say("manifest", "%s v%s — %d artifact(s)", manifest.Module, manifest.Version, artifactCount(manifest))
// What it is made from, for its source fingerprint: the module's own tree first.
src := newSourceInputs(manifest.Module)
if src.tree, err = gitTree(ctx, run, tree, path); err != nil {
src.notPinned("its tree could not be named: " + err.Error())
}
// A build-time credential, written into the build context as .npmrc, but ONLY for a module that
// asks for it: a `package` artifact (which publishes), or an image whose Dockerfile COPYs .npmrc.
// Writing it into every context would put a per-run credential in `COPY . .` of modules that
@@ -178,6 +190,7 @@ func Build(ctx context.Context, run Runner, publish Publisher,
return Result{}, fmt.Errorf("cannot write the package-registry credential for the build: %w", err)
}
say("packages", "resolving %s from the mesh's package registry", npmrc.Scope)
src.notPinned("it resolves packages from the mesh's registry at build time")
}
var built []catalogue.Built
@@ -206,6 +219,7 @@ func Build(ctx context.Context, run Runner, publish Publisher,
return Result{}, err
}
stoodOn = bases
src.bases = append(src.bases, bases...)
if len(args) > 0 {
say("bases", "%d resolved from what the mesh holds", len(args)/2)
}
@@ -215,7 +229,7 @@ func Build(ctx context.Context, run Runner, publish Publisher,
sort.Slice(artifacts, func(i, j int) bool { return artifacts[i].Name < artifacts[j].Name })
for _, a := range artifacts {
say("artifact", "%s (%s%s) — starting", a.Name, a.Kind, langSuffix(a))
made, err := one(ctx, run, publish, manifest.Module, within, workspace, commit, credentials, a, args, held, npmrcPath, npmrc, seatBases, say)
made, err := one(ctx, run, publish, manifest.Module, within, workspace, commit, credentials, a, args, held, npmrcPath, npmrc, seatBases, src, say)
if err != nil {
say("artifact", "%s FAILED: %v", a.Name, err)
return Result{}, err
@@ -231,8 +245,20 @@ func Build(ctx context.Context, run Runner, publish Publisher,
return Result{}, err
}
say("done", "%s at %s — %d artifact(s) pinned", manifest.Module, short(commit), len(built))
fingerprint := src.fingerprint()
if fingerprint == "" {
say("source", "no source fingerprint: %s", orNoTree(src.unpinned))
}
return Result{Manifest: resolved, Commit: commit, Built: built,
Against: against(within, manifest, stoodOn), Read: readBy(manifest)}, nil
Against: against(within, manifest, stoodOn), Read: readBy(manifest), Source: fingerprint}, nil
}
// orNoTree is why a build has no source fingerprint, for its log.
func orNoTree(why string) string {
if why == "" {
return "its tree was not named"
}
return why
}
// Log is where a build says what it is doing, step by step. Nil is silent — the tests pass none,
@@ -443,7 +469,7 @@ func wantsPackages(manifest catalogue.Manifest, within string) bool {
func one(ctx context.Context, run Runner, publish Publisher,
module, tree, workspace, commit, credentials string, a catalogue.Artifact, args []string,
held map[string]string, npmrc string, registry Npmrc, seats map[string]string,
held map[string]string, npmrc string, registry Npmrc, seats map[string]string, src *sourceInputs,
say func(step, format string, args ...any)) (catalogue.Built, error) {
switch a.Kind {
@@ -525,6 +551,11 @@ func one(ctx context.Context, run Runner, publish Publisher,
if err != nil {
return catalogue.Built{}, fmt.Errorf("%s: %s's context: %w", module, a.Name, err)
}
if t, err := gitTree(ctx, run, cloned, ""); err != nil {
src.notPinned(a.Name + "'s context could not be named: " + err.Error())
} else if src != nil {
src.contexts[a.Name] = t
}
// docker build accepts -f outside the context it is given; the recipe stays exactly
// where it was read from and validated against, absolute so the working directory
// switching to the cloned context does not change which file that is.
@@ -582,6 +613,14 @@ func one(ctx context.Context, run Runner, publish Publisher,
"holds no copy of it. Build %s first",
module, a.Name, chain.Language, chain.Base, chain.Artifact, chain.Base)
}
if src != nil {
src.toolchains[a.Name] = toolchainOf(chain, base)
}
if chain.Language == "typescript" {
if own, _ := ownDependencies(tree); len(own) > 0 {
src.notPinned(a.Name + " resolves packages of its own at build time")
}
}
// The module's own packages first, where the compiler and the bundler resolve them from
// (dependencies.go); nothing at all for a module whose package.json names only the SDK.
if err := installOwn(ctx, run, tree, chain, base, registry, say); err != nil {
@@ -623,6 +662,7 @@ func one(ctx context.Context, run Runner, publish Publisher,
// there is no Publisher call — the container itself publishes, with the credential the
// build was handed.
say("package", "building and publishing %s (%s)", a.Name, a.Language)
src.notPinned(a.Name + " is a package, built from what the registry holds when it is built")
reference, err := publishPackage(ctx, run, module, tree, a, npmrc, say)
if err != nil {
return catalogue.Built{}, fmt.Errorf("%s: publishing %s failed: %w", module, a.Name, err)