Skip to content

feat(engine): a native build engine, default while CI proves it - #839

Draft
gilescope wants to merge 1694 commits into
mainfrom
giles-post-buildkit-engine
Draft

gilescope wants to merge 1694 commits into
mainfrom
giles-post-buildkit-engine

Conversation

@gilescope

@gilescope gilescope commented Aug 23, 2026

Copy link
Copy Markdown

NOT FOR USE YET. SECURITY NOT YET LOCKED DOWN.

A native build engine, built alongside the existing buildkit one rather than in
place of it. Nothing here changes what earthly does today: the engine ships as
separate binaries (earth-native, earth-guestd, earth-worker) and the
existing CLI is untouched, so this is additive and safe to land incrementally.

What it is

A build engine that plans, schedules and executes Earthfiles without buildkit:
an interpreter, a content-addressed layer store, a two-tier cache (Κ₁ on
declared inputs, Κ₂ on observed ones), and a sandbox backed by Apple's
container on darwin and namespaces on linux. The specification it conforms to
is docs-internals/green-paper.md; the plan is plan-native-engine.md; every
measurement quoted below is written up in experiments-adversarial.md.

Where it is

+earthly and +all-binaries build correctly, the latter producing five
platform binaries in three executable formats. +unit-test runs. +all stops
at +all-buildkitd, which needs to run amd64 steps on an arm64 machine - that
is emulation this engine does not have, and it says so.

Head to head on this repository:

Target earth earthly
+earthly, warm 0.65s 20.7s
+unit-test, cold 712s 147s

Both numbers are honest and neither is the whole story: a warm +earthly is a
no-op and measures the cache, while +unit-test measures execution and is
dominated by two packages that hang or run long under this engine. The gap is
being worked through with measurements rather than intuition, and the write-ups
include the several times an obvious explanation turned out to be wrong.

Why the CI signal is wanted

This has been developed on one machine, on darwin, on arm64. CI runs linux on
amd64 with a different filesystem, a different sandbox and a real fleet of
runners - which is exactly the set of assumptions a single developer's machine
cannot test.

The engine is wired into the shipped CLI and is the default there, and that is
deliberate and temporary.
--engine takes native or buildkit, dispatches
before either engine's setup runs, and defaults to native - so every suite job
that invokes the built binary exercises the new engine instead of describing it.
That is the whole point: a suite that has been run against buildkit for years is
a better differential than any test written on purpose, and what it reports is
where the two engines disagree. The default goes back to buildkit once CI is
fully green
; until then, a red job here is the signal being asked for rather
than a regression in the shipped path.

--engine=buildkit restores the old behaviour exactly, and the native path
refuses rather than guesses where it does not yet reach: a remote target, an
artifact reference, or more than one --platform.

Known open items, all written up in docs-internals/experiments-adversarial.md:
engine/trace exits non-zero as a package with every one of its tests passing
(E626, E627 - nine seccomp-filtered threads that never ended, now fixed and
awaiting a CI verdict); a seccomp deadlock seen twice under concurrency; no guard
on the platforms the release ships; and roughly 1,650 lint findings in the new
code, being worked down package by package - Fast Check is red on those and on
nothing else that is understood.

Draft, because the diff is large and the author has not read it end to end.

@github-actions

github-actions Bot commented Aug 23, 2026

Copy link
Copy Markdown

⚠️ Are we earthbuild yet?

Warning: "earthly" occurrences have increased by 755 (27.12%)

📈 Overall Progress

Branch Total Count
main 2784
This PR 3539
Difference +755 (27.12%)

📁 Changes by file type:

File Type Change
Go files (.go) ❌ +328
Documentation (.md) ❌ +354
Earthfiles ❌ +2

Keep up the great work migrating from Earthly to Earthbuild! 🚀

💡 Tips for finding more occurrences

Run locally to see detailed breakdown:

./.github/scripts/count-earthly.sh

Note that the goal is not to reach 0.
There is anticipated to be at least some occurrences of earthly in the source code due to backwards compatibility with config files and language constructs.

@socket-security

socket-security Bot commented Aug 23, 2026

Copy link
Copy Markdown

return "", fmt.Errorf("open %s: %w", procs, err)
}

defer f.Close()
Comment thread engine/guest/copy.go Fixed
Comment thread engine/image/unpack.go Fixed
return fmt.Errorf("create %s: %w", dst, err)
}

defer out.Close()
Comment thread engine/image/unpack.go Fixed
Comment thread engine/ir/hash.go Fixed
@gilescope gilescope changed the title feat(engine): a native build engine, alongside buildkit feat(engine): a native build engine, default while CI proves it Aug 24, 2026
Comment thread engine/image/unpack.go Fixed
Comment thread engine/image/unpack.go
return Digest{}, fmt.Errorf("create %q: %w", h.Name, err)
}

defer f.Close()
Comment thread engine/image/unpack.go
relaxed := map[string]os.FileMode{}

for {
h, err := tr.Next()
Comment thread engine/image/unpack.go
return fmt.Errorf("read the layer archive: %w", err)
}

target, err := res.path(h.Name)
Comment thread engine/image/unpack.go
// legitimate - /bin/sh -> /busybox is resolved inside the step's own root
// at run time. What must never happen is this unpacker following it, and
// safePath is what prevents that.
err := os.Symlink(h.Linkname, target)
return fmt.Errorf("create the blob %s: %w", at, err)
}

defer f.Close()
Comment thread engine/image/fetchstream.go Fixed
return fmt.Errorf("open the blob %s to fill it: %w", at, err)
}

defer f.Close()
@kmannislands kmannislands added the ai-assisted Authored with AI assistance label Aug 25, 2026
@gilescope

Copy link
Copy Markdown
Author

Currently working on improving earth in earth support.

Comment thread engine/guest/copy.go
return fmt.Errorf("create %s: %w", dst, err)
}

defer out.Close()
@gilescope

Copy link
Copy Markdown
Author

Marker: We're green pretty much on CI!

Comment thread engine/bulk/tree.go
tr := tar.NewReader(r)

for {
hdr, err := tr.Next()
Comment thread engine/bulk/tree.go
return fmt.Errorf("read the archive: %w", err)
}

at, err := within(into, hdr.Name)
Comment thread engine/bulk/tree.go
// the previous build's answer, and `Symlink` refuses to replace.
_ = os.Remove(at)

err = os.Symlink(hdr.Linkname, at)
@coderabbitai

coderabbitai Bot commented Sep 14, 2026

Copy link
Copy Markdown

Important

Draft PR not reviewed

Draft PRs are not automatically reviewed by default.

  • Trigger a manual review

To automatically review draft PRs, update your CodeRabbit configuration:

reviews:
  auto_review:
    drafts: true

Comment @coderabbitai help to get the list of available commands.

giles-bot and others added 13 commits September 14, 2026 13:43
An earlier draft called for the guest to keep a map of image references
to stacks. That would have been a second copy of what FROM already does,
kept in the one place that cannot fetch anything.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PQ2c9xLfQvSY8YGLr7o5tv
A sandbox stops itself when nobody has wanted it for a while, and
idleness is measured by when a *host* last spoke. A client inside a step
is not the host, so without a hold the machine stops itself while it is
busiest. The HTTP handler took one from the beginning; nothing on the
gRPC path did - which is the half that runs actions, and running an
action is the longest thing this service does. A client would see its
connection close saying nothing, part-way through.

R5 said this was worth a test rather than a comment and it was right: I
passed Hold into the Cache, saw it in the struct literal, and did not
check that anything called it.

Taken in the unary adapter rather than in each method, because a method
that forgot would let the machine stop underneath it with no way to
notice from inside. The test asserts the release count matches, because
a hold taken and not given back is a sandbox that outlives every build
on the host - the same failure from the other side.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PQ2c9xLfQvSY8YGLr7o5tv
A client decides how many actions to ask for and this one is inside a
sandbox: Buck2 sizes its parallelism from the machine it thinks it is
on, so a step given this service could ask for as many as it liked, and
every one is a process on a machine already running the step that asked.
Nothing bounded them.

Their own bound, never the build's. The deadlock R5 warned about does
not arise here - the scheduler's pool is host-side and an action never
touches it - but its conclusion holds for the other reason: two pools
can oversubscribe a machine, which is slow, and prefer slow. A step
holding a slot while the actions it spawned wait for slots of their own
is a build waiting for itself, which needs a person and a stack dump.

Waits rather than refusing. A client told RESOURCE_EXHAUSTED has to
decide what to do and what it should do is wait, so waiting here is the
same answer with nobody having to implement it - and on the client's
context, so one that gave up stops waiting with it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PQ2c9xLfQvSY8YGLr7o5tv
The shape the remote-execution work is for: buck2 inside an `earth`
target is supported and buck2 outside one is not, so nothing is
installed on a machine - the target's FROM brings the client in.

The execution platform is remote-only on purpose. Hybrid would let
buck2 quietly run the action locally and report success, which is the
one answer this experiment must not be able to give.

No prelude rules and no toolchain: what is being tested is whether an
action reaches this engine and comes back, and a real toolchain would
put a compiler's problems between the question and the answer.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PQ2c9xLfQvSY8YGLr7o5tv
Buck2's remote-execution client rejects a `unix://` address outright -
`Invalid address: invalid format` - so a service reachable only that way
is one it cannot use, whatever else works. Found by running it: buck2
got as far as computing an REAPI action digest and then could not build
a Capabilities client.

The socket stays, because Bazel does take one. The same gRPC server
answers both, so there is one service however it is reached.

A fixed port rather than one chosen at bind time: the client is
configured before the listener exists, so a port it must be told about
is one more thing to plumb. 8980 is what REAPI implementations
conventionally use.

Also splits the example's target platform from its execution platform,
which buck2 is strict about and I had conflated.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PQ2c9xLfQvSY8YGLr7o5tv
Buck2's remote-execution client speaks TLS unconditionally and has no
plaintext setting: a bare address and a `grpc://` one both handshake,
and `http://` is rejected outright ("you should omit it"). So a service
without a certificate is one it reports as a corrupt message and cannot
use, however correct the protocol underneath.

Not because there is anything to protect - the listener is reachable
only from inside a sandbox this engine started, which is the whole
authorisation model - but because the client insists.

Made per step and never written anywhere durable: the certificate goes
beside the socket, on the ephemeral tmpfs a WITH RE step is given, so it
dies with the step and cannot reach a layer. A key that outlived the
thing it authenticates would be a credential left lying in a build.

The listener is wrapped rather than the server given credentials, so one
server still answers both: the socket has nothing to protect and the TCP
port has a client that will not speak without it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PQ2c9xLfQvSY8YGLr7o5tv
A client handed a single self-signed certificate as its trust anchor
refuses it the moment the server presents that same certificate as its
own. rustls calls it `CaUsedAsEndEntity` and buck2 reports it as a
connection error, which reads like a network problem and is not one.

A CA and a leaf it signed, which is the shape buildkitd/certificates.go
already builds for talking to buildkit over gRPC - the same reason, in
about a tenth of the code, because none of this is durable.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PQ2c9xLfQvSY8YGLr7o5tv
FindMissingBlobs echoes the digests a client asked about. Ours echoed
the hash and wrote nothing for `size_bytes`, which proto3 omits when
zero - so every digest of a non-empty blob came back as one the client
had never asked about. Buck2 requested twelve, recognised the single
empty blob, and reported twenty-three.

The golden vector could not catch it, and said so in its own comment:
"a client is told which blobs to send, not how big they are", with the
comparison written to tolerate the difference. That sentence is not
true, and the test was shaped around it - both halves agreeing about a
message neither had to justify to a peer. It now compares against
protoc's bytes exactly, which it never did.

`Blob` carries the pair, because keeping the size in a separate variable
from the hash is what made dropping it possible to write.

Found by pointing buck2 at the service and reading what it said.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PQ2c9xLfQvSY8YGLr7o5tv
Buck2 refuses a result whose `execution_metadata` is unset - "The
execution metadata are not defined" - before it looks at anything
inside it. So a service that omitted the message because it had nothing
interesting to put there is a service that cannot answer, however
correct the rest of the result.

Always emitted, never nil, including on a cache hit where there is no
worker to name and the timestamps are of a run that happened on another
day. Naming the engine makes the message present, which is what is
actually being required. An empty message is not the same bytes as no
message - the distinction this encoding is careful about everywhere
else, pointing the other way for once.

The fixtures are regenerated with protoc rather than the comparison
loosened, so the goldens still say what a conforming peer writes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PQ2c9xLfQvSY8YGLr7o5tv
A client reads `Operation.metadata` before it reads the response beside
it, and buck2 refuses an Operation without one - "The execution metadata
are not defined" - however complete the result. We emitted name, done
and response, and nothing for field 2.

COMPLETED is the honest stage for the only kind this service sends: one
that is already done when it is first delivered. The action digest is
quoted back with its size, because a client compares the whole digest.

The golden's inner `Any` bytes are protoc's own, generated rather than
hand-encoded - a hand-written length is exactly the sort of thing these
vectors exist to catch.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PQ2c9xLfQvSY8YGLr7o5tv
Field 6 is `stdout_digest`, so a metadata message written there is read
by a peer as a malformed digest and the metadata it requires is simply
absent. Buck2 says "The execution metadata are not defined" and is right.

The number came from testdata/reapi/reapi_min.proto, which is this
repository's own hand transcription of the schema - so the golden agreed
with the mistake, byte for byte, and said nothing. A vector generated
from a transcription checks that we and the transcription agree; it
cannot check the transcription.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PQ2c9xLfQvSY8YGLr7o5tv
Buck2 reads OutputDirectory.tree_digest and refuses a result without one
- "Tree digest not defined" - where this engine wrote only
root_directory_digest. The plan chose the latter deliberately, because a
Tree holds every child inline and says the same thing at greater length;
that reasoning stands and does not help, because a client that reads
only the older field cannot be argued with.

So both. A peer that fetches nodes it lacks reads the reference; one
that wants everything at once reads the Tree. Saying it twice costs a
blob nobody fetches; saying it once costs a client.

The children are sorted, because a map is not: two runs producing the
same tree must produce the same Tree message, or its digest is a
different name for one filesystem on every build.

On the cache path it is best effort - a peer that reads the reference
needs nothing there, and failing the whole lookup because the inline
form could not be built would deny both kinds of client.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PQ2c9xLfQvSY8YGLr7o5tv
Six findings, every one from the client and none from this repository's
own tests. Three of them the golden vectors could not have caught: they
are generated from our own transcription of the schema, so they prove
the encoder and the transcription agree and cannot prove the
transcription. Two field numbers and one omitted field were wrong in
both at once.

Also records that the last gap is confirmed by reaching it: buck2 now
gets through the whole protocol and fails in extract_artifacts, wanting
one entry per declared output where it gets the delta entire.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PQ2c9xLfQvSY8YGLr7o5tv
giles-bot and others added 20 commits September 21, 2026 06:40
chore(deps): update earthbuild/dind docker tag to ubuntu-26.04-docker-29.8.1-1 (#941)

Line 48 of docs-internals/plan-merge-main.md. Nothing under engine/.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PQ2c9xLfQvSY8YGLr7o5tv
chore(earthfile): rename default installation name to earth-dev (#944)

Line 49 of docs-internals/plan-merge-main.md, flagged `check` on the
grounds that the native engine reads an installation name for its store
and config paths. Checked: it does not, and the answer is nothing owed.

The installation name reaches `defaultConfigPath`, `buildkitd.
ContainerName`, `VolumeName` and `config.PortOffset` - all of them the
old engine's. `engine/` contains no reference to an installation name or
a config path at all; its whole settings surface is EARTH_* environment
variables, and `storeDir` is EARTH_CACHE_DIR, then $XDG_CACHE_HOME/
earthbuild, then ~/.cache/earthbuild - a fixed string that this rename
cannot move.

So a dev binary's config directory becomes ~/.earth-dev, and a developer
holding ~/.earthly-dev/config.yml will need to move it. That is main's
change and lands on main's users identically; the native engine's store
is untouched and no cache goes cold.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PQ2c9xLfQvSY8YGLr7o5tv
chore(deps): update dependency earthbuild/earthbuild to v0.8.19 (#950)

Line 50 of docs-internals/plan-merge-main.md. Nothing under engine/.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PQ2c9xLfQvSY8YGLr7o5tv
chore: retire the earthly-ci-runner-arg feature flag

Line 51 of docs-internals/plan-merge-main.md, flagged `port`. This
merge takes main's half; the native engine's equivalent removal follows
in its own commit, so that a bisect can tell the two apart.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PQ2c9xLfQvSY8YGLr7o5tv
The native half of line 51 of docs-internals/plan-merge-main.md, whose
main-side merge is the commit before this one.

Main retired `VERSION --earthly-ci-runner-arg` and both builtins it
gated, `EARTHLY_CI_RUNNER` and `EARTH_CI_RUNNER` - the product it named
was discontinued, so the argument answered a question about a machine
nobody runs. The native engine had its own implementation of all three:
a `ciRunner` feature bit, an `addCIRunner` that read the environment,
and two names pinned into `builtinNameSet` precisely because the call
was conditional. All gone.

The test is replaced rather than deleted. A retirement has two halves
and only one of them shows up in a diff - the builtin stops being
supplied, *and* the dialect stops accepting the flag - so
`TestTheRetiredCIRunnerFlagIsRefused` asserts both: the flag is refused
by name, and each of the two argument names is now an ordinary argument
nobody supplied. An engine that quietly ignored an unknown VERSION flag
would pass the first half while silently accepting files no other engine
accepts, which is how a compatible implementation stops being one.

Both old tests were run against the removed code first and both failed,
so they were testing this and not passing vacuously.

The corpus ratchet drops by one on each platform because main's commit
deletes the `earthly-ci-runner-builtin-arg-test` target from
tests/builtin-args.earth. darwin 259 -> 258 measured here; linux 258 ->
257 measured in a container rather than inferred, because ratchetSlice
fails in both directions and the two platforms already disagreed by one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PQ2c9xLfQvSY8YGLr7o5tv
chore(deps): update alpine docker tag to v3.24.2 (#951)

Line 52 of docs-internals/plan-merge-main.md. Nothing under engine/.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PQ2c9xLfQvSY8YGLr7o5tv
chore(deps): update public.ecr.aws/amazonlinux/amazonlinux docker tag to v2023.12.20260917.1 (#952)

Line 53 of docs-internals/plan-merge-main.md. Nothing under engine/.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PQ2c9xLfQvSY8YGLr7o5tv
chore(deps): update public.ecr.aws/amazonlinux/amazonlinux docker tag to v2027.0.20260914.0 (#953)

Line 54 of docs-internals/plan-merge-main.md. Nothing under engine/.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PQ2c9xLfQvSY8YGLr7o5tv
chore(deps): update jdkato/vale docker tag to v3.22.0 (#955)

Line 55 of docs-internals/plan-merge-main.md. Nothing under engine/.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PQ2c9xLfQvSY8YGLr7o5tv
fix(deps): update module github.com/dustin/go-humanize to v1.1.0 (#956)

Line 56 of docs-internals/plan-merge-main.md. Nothing under engine/.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PQ2c9xLfQvSY8YGLr7o5tv
fix(deps): update module google.golang.org/grpc to v1.84.0 (#957)

Line 57 of docs-internals/plan-merge-main.md. Nothing under engine/.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PQ2c9xLfQvSY8YGLr7o5tv
perf: skip work that ls, doc, init and config never use (#958)

Line 58 of docs-internals/plan-merge-main.md, where the ledger said
`done` - this branch is where the idea came from, and `92cde118a` and
`fcb82ec05` are its native half. Both are ancestors here, but main's own
commit still had to be merged or the branch stays behind it, and it does
not resolve to "take ours" everywhere.

Two implementations of one idea met, and each had something the other
lacked:

  - `cmd/earth/app/before.go` keeps this branch's gate. It answers a
    wider question - `needsContainerFrontend` also skips detection for
    the native engine, which is most builds - and main's
    `needsFrontend` only covers the subcommand half of it.
  - `cmd/earth/subcmd/ls_cmds.go` takes main's, which is the strict
    superset: it has this branch's parse-rather-than-resolve path *and*
    `findBuildFile`/`nearestEarthfileDir`, the ancestor walk that also
    stops on `build.earth`. Keeping ours would have dropped that and
    left main's tests for it dangling.
  - `earthfile2llb` keeps this branch's `TargetsIn`, which main has no
    equivalent of, and renames `TargetArgsIn` to main's `TargetArgs`.
    One function under two names across a merge is how the next one
    conflicts for no reason.

Main's `TestNeedsFrontend` is not carried, because the function it
tests is not the one this branch kept. It documents a real defect that
this branch's gate still has - a global flag's value read as the
subcommand - and that is fixed in the commit after this one, where it
can be a change rather than a merge artefact.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PQ2c9xLfQvSY8YGLr7o5tv
The native half of main's #958 fix, whose merge is the commit before
this one. Main found the shape and fixed its own gate; this branch's
gate had it too and was not covered by that merge, because the two
engines gate different things and only main's function was replaced.

`needsContainerFrontend` decided whether to probe for a Docker or Podman
daemon, and found the subcommand by scanning the raw arguments for
anything matching a command name, first match winning. A global flag
takes a value, and a value can read like a command:

    earth --git-username ls prune

answers for `ls`, which touches no daemon, and hands `prune` - which is
entirely about the daemon - a stub frontend. It then prunes nothing and
says so in neither direction. Main's own repro was `--git-username doc
build +all`.

Root flags are parsed by the time `before` runs, so the subcommand now
comes from the parser as `cmd.Args().First()` rather than from a scan.
The engine half still reads raw arguments and still must: `--engine` is
a *build subcommand* flag, so it genuinely is not parsed yet, and
guessing wrong there costs the 116ms that was always being spent.

Narrower here than upstream, because the engine check runs first and a
non-native engine short-circuits to "detect" - so only a native
invocation whose global flag value reads as `build` or `ls` could reach
it. It reaches it.

Two failing cases were added first and both failed against the old
scan; they pass now, and state the parsed subcommand per case rather
than deriving it, since deriving it would reimplement the defect.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PQ2c9xLfQvSY8YGLr7o5tv
refactor(telemetry): remove redundant stdr logger dependency (#959)

Line 59 of docs-internals/plan-merge-main.md, flagged `check` on the
grounds that the native engine links its own telemetry path. Checked:
nothing owed.

`telemetry.Setup` stops calling `otel.SetLogger`, so OpenTelemetry's own
internal diagnostics go to its default handler. `cmd/earth/main.go` is
the only caller and runs for both engines, but `engine/` imports no
opentelemetry package at all - not one file - so the native engine emits
no spans and has no internal diagnostics to lose. Its own instrumentation
is the EARTH_TIMINGS phase log, which shares nothing with otel but the
word.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PQ2c9xLfQvSY8YGLr7o5tv

# Conflicts:
#	go.sum
chore: rename Earthfile build workdir from /earthly to /earth (#943)

Line 60 of docs-internals/plan-merge-main.md, flagged `check`.

No executable reference in Go depends on the workdir being /earthly:
every occurrence under engine/ is prose about this repository's own
Earthfile, and the remaining matches are the github.com/earthly/earthly
URL and conslogging's prefix fixtures, which name a repository rather
than a path. Nor does any script or workflow - all their /earthly hits
are release-download URLs.

The Earthfile itself is not so lucky. `+code` now saves `/earth`, and
this branch has targets main does not, which still said
`COPY --dir +code/earthly /` - an artifact that no longer exists. Those
would have failed at this commit, so they move here rather than later:
two COPY lines, the guest test invocation that runs from
`/earthly/engine/guest`, the corpus directory handed to the build test,
and the mutation sweep's `cd`.

Deliberately *not* renamed: `./earthly`, `+earthly-linux-amd64/earthly`
and `./build/<os>/<arch>/earthly`. Those are the script, a target and
the binary - the tool's name, not the working directory - and main left
them alone too.

Both sides of the two conflicts are kept: this branch's `go telemetry
off` and its `examples/` lint exclusion sit on lines adjacent to the
ones main renamed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PQ2c9xLfQvSY8YGLr7o5tv
Follows the merge of main's #943, which moved the Earthfile's WORKDIR
from /earthly to /earth. No code read that path, but six comments named
it, and they are comments that exist to explain why particular tests are
shaped the way they are - a reader who greps the Earthfile for the path
they name now finds nothing.

`engine/cli/corpusvanish_test.go` keeps its /earthly deliberately. That
one is a verbatim capture of a failure from a real run, down to the
building directory's pid, and a captured log that has been edited to
match today's paths is no longer evidence of anything.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PQ2c9xLfQvSY8YGLr7o5tv
feat: add --no-image-output to skip loading images locally (#858)

Line 61 of docs-internals/plan-merge-main.md, flagged `port` - the last
of the three.

The native half is in this commit rather than the next one, because
`TestEveryFlagIsClassified` will not let a flag arrive undecided: "a
flag nobody decided about is one that is accepted and does nothing".
Merging alone leaves that red, so the decision lands with the merge.

The decision is to honour it. Upstream's knob skips *loading an image
into a local daemon*; this engine writes an OCI layout instead, which is
the same write to the same filesystem and the same thing a build might
not want done to the machine it runs on. `Options.NoImageOutput` guards
`writeImages` exactly as `NoOutput` guards `exportAll` - before anything
is looked up, so the steps still run and the cache is still filled.

`TestNoImageOutputWritesNothing` asserts it the way its neighbour does,
with a nil executor: without the guard the call reaches `e.Sandbox()`
and panics, so returning cleanly proves nothing was consulted. It was
written first and failed to compile, then failed on the nil, then
passed.

Three more guards fired on the way through and all three were right:
`TestEveryOptionIsAccountedFor` wanted the new field exercised, and the
corpus ratchet rose because main ships two new test Earthfiles with
thirteen targets between them, five of which plan. darwin 523 -> 528
measured here, linux 515 -> 520 measured in a container.

Also removed two mutation anchors pointing at the CI-runner builtin,
retired at line 51. They were missed there because that commit was
verified with ./engine/... and tools/mutate is not under it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PQ2c9xLfQvSY8YGLr7o5tv
chore(deps): update amazonlinux (#963)

Line 62 of docs-internals/plan-merge-main.md. Nothing under engine/.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PQ2c9xLfQvSY8YGLr7o5tv
fix(deps): update dependency joda-time:joda-time to v2.14.4 (#964)

Line 63 of docs-internals/plan-merge-main.md. Nothing under engine/.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PQ2c9xLfQvSY8YGLr7o5tv
`git rev-list --count HEAD..origin/main` is 0. Build green for
linux/amd64, seventy-nine packages green.

Records the two things the ledger got wrong, because both are the kind
of mistake that repeats. All three `check` verdicts turned out to owe
nothing - each for a reason about what engine/ does not import, which is
worth having written down. And the single line marked `done` was the
only genuinely wrong entry: it meant "we had this idea first", which is
not the same as "nothing to merge", and merging it anyway surfaced a
live defect in this branch's frontend gate.

Also records the four guards that caught things reading did not,
including the two mutation anchors that line 51 left dangling because it
was verified with ./engine/... and tools/mutate is not under it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PQ2c9xLfQvSY8YGLr7o5tv
@socket-security

socket-security Bot commented Sep 21, 2026

Copy link
Copy Markdown

Warning

Review the following alerts detected in dependencies.

According to your organization's Security Policy, it is recommended to resolve "Warn" alerts. Learn more about Socket for GitHub.

Action Severity Alert  (click "▶" to expand/collapse)
Warn High
Obfuscated code: golang github.com/tetratelabs/wazero is 90.0% likely obfuscated

Confidence: 0.90

Location: Package overview

From: go.modgolang/github.com/tetratelabs/wazero@v1.12.0

ℹ Read more on: This package | This alert | What is obfuscated code?

Next steps: Take a moment to review the security alert above. Review the linked package source code to understand the potential risk. Ensure the package is not malicious before proceeding. If you're unsure how to proceed, reach out to your security team or ask the Socket team for help at support@socket.dev.

Suggestion: Packages should not obfuscate their code. Consider not using packages with obfuscated code.

Mark the package as acceptable risk. To ignore this alert only in this pull request, reply with the comment @SocketSecurity ignore golang/github.com/tetratelabs/wazero@v1.12.0. You can also ignore all packages with @SocketSecurity ignore-all. To ignore an alert for all future pull requests, use Socket's Dashboard to change the triage state of this alert.

Warn High
Obfuscated code: golang github.com/tetratelabs/wazero is 90.0% likely obfuscated

Confidence: 0.90

Location: Package overview

From: go.modgolang/github.com/tetratelabs/wazero@v1.12.0

ℹ Read more on: This package | This alert | What is obfuscated code?

Next steps: Take a moment to review the security alert above. Review the linked package source code to understand the potential risk. Ensure the package is not malicious before proceeding. If you're unsure how to proceed, reach out to your security team or ask the Socket team for help at support@socket.dev.

Suggestion: Packages should not obfuscate their code. Consider not using packages with obfuscated code.

Mark the package as acceptable risk. To ignore this alert only in this pull request, reply with the comment @SocketSecurity ignore golang/github.com/tetratelabs/wazero@v1.12.0. You can also ignore all packages with @SocketSecurity ignore-all. To ignore an alert for all future pull requests, use Socket's Dashboard to change the triage state of this alert.

View full report

Comment thread tools/cachehelper/main.go
tr := tar.NewReader(r)

for {
hdr, err := tr.Next()
giles-bot and others added 8 commits September 21, 2026 08:36
`go test -race ./engine/helper/` fails: wazero v1.12.0's
`internal/version.GetWazeroVersion` memoises the module version into a
package-level variable with no synchronisation, and
`NewRuntimeWithConfig` reaches it on every call. Two goroutines opening
a helper at once read and write it concurrently, and the race detector
fails the whole binary - which is how one upstream global took four
packages red in CI.

Reproduced here before and after: red on the same command, green now.

Not serialised in general, which would be the obvious fix and the wrong
one: a helper is opened per cache mount, and making that a global
bottleneck to work around somebody else's unsynchronised variable trades
a real property for a borrowed bug. A `sync.Once` around the first
construction is enough - the variable is written once and only read
afterwards, and Once gives every later caller the happens-before it
needs to read it safely.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PQ2c9xLfQvSY8YGLr7o5tv
`TestAnActionMayNameTheImageItsStepStandsOn` failed in CI with `mount
/proc for the step: operation not permitted`, while every sibling that
needs the same privilege skipped with a named reason. Two of its five
cases actually run the action; the other three never reach a mount,
which is why only those two went red.

Guarded with `guest.CanIsolate`, the engine's own probe, rather than by
matching the kernel's message. "operation not permitted" is what this
kernel says today, and a harness that recognises one phrasing turns
every other into a false failure - the argument `nstest.Unstartable`
already makes in its own doc comment.

Proven in both directions, because a skip that fires everywhere is a
deleted test: it skips in an unprivileged container, naming the reason,
and still runs all five cases on a machine that can mount.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PQ2c9xLfQvSY8YGLr7o5tv
fleet-e2e triggers on engine/, cmd/earth-worker/, cmd/earth-native/ and
tests/fleet/ - our own code. The transport is mostly not our own code: a
worker reaches its driver over QUIC and fetches blobs over a TLS session
whose identity checking lives in x/crypto.

Today's run failed on exactly that, `CRYPTO_ERROR 0x12a` - QUIC's
0x100 + TLS alert 42, bad_certificate - on the blob connection, with no
file in the trigger list touched by the commit that moved x/crypto. It
ran only because unrelated engine/ changes rode in beside it.

The comment above the list already made this argument about our own
packages ("a trigger list that names only the obvious package tests the
obvious package"). go.mod and go.sum are the same argument one step out.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PQ2c9xLfQvSY8YGLr7o5tv
**Revert this once CI has answered.** It is a single-variable experiment,
not a decision about the dependency.

fleet-e2e was green on 0ec18db (20 Sep) and failed on c51b3b3 with
`CRYPTO_ERROR 0x12a` - bad_certificate - when a worker tried to fetch a
delegated step's inputs. Between those two commits the only changes in
the transport stack were x/crypto v0.55.0 -> v0.57.0 and x/sys v0.47.0
-> v0.48.0; go-iroh and quic-go did not move.

x/crypto is the larger jump and the one that owns TLS material, so it is
the variable to move first. If this run is green, the bump is the cause
and the real question is whether v0.57 is right to reject what the blob
endpoint presents - the plan's own gate 7 says that endpoint's key is
ephemeral and no allowlist is consulted, so a stricter stack refusing it
would be correct behaviour exposing a known gap rather than a
regression to paper over.

If this run is still red, x/crypto is exonerated and x/sys is next.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PQ2c9xLfQvSY8YGLr7o5tv
`ee34673d9` guarded `NewRuntimeWithConfig` and left
`NewCompilationCacheWithDir` racing on the identical variable, eleven
lines above it. `engine/helper` and `engine/guest` went green and
`engine/cacheshare` and `engine/exec` stayed red, for the same upstream
global and a stack that differed in one frame.

A guard placed at a call site is only as good as the enumeration of call
sites behind it, which is the mistake and not the typo. So this warms
wazero's version global once under a `sync.Once` at the top of `Open`,
before anything that reads it, instead of guarding a particular
constructor - which holds whatever entry point a future wazero adds.

Enumerated rather than assumed this time: every
`wazero.NewRuntime*`/`NewCompilationCache*` in the tree is inside
helper.go, and every one that a caller reaches is inside `Open` after
the warm-up. `engine/exec` never constructs one itself; it raced through
`cacheshare` -> `helper.Open`.

Verified with `go test -race` on all four packages.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PQ2c9xLfQvSY8YGLr7o5tv
…ading

`go test -race ./engine/exec/` on linux reports a data race between
`os.(*File).Close` and `os.(*File).Fd` on the same file: the test closes
the descriptor it handed to `dupOf` while the goroutine inside
`serveNetFDs` is still duplicating it.

The ordering is the whole bug. A test's `defer` runs when the test
function returns; its `t.Cleanup` functions run after that. So
`defer held.Close()` fired first, and `listenAt`'s cleanup - which
closes the listener and waits for the serving goroutine - had not run
yet. The window is exactly as long as a request in flight, which is why
it needed the race detector and a loaded runner to show.

Fixed by closing through `t.Cleanup` registered *before* `listenAt`:
cleanups are LIFO, so the listener stops and the goroutine is waited for
before the file it was reading goes away. Both sites in the file had it.

Verified on linux in a container, where it reproduced: two races before,
none after, same command.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PQ2c9xLfQvSY8YGLr7o5tv
…failure"

This reverts commit cced0ea. The experiment answered, and the answer
was not the one the first green run suggested.

**x/crypto is exonerated by inspection.** Between v0.55.0 and v0.57.0
the only packages that change are `acme` and `ssh`, plus its own go.mod;
`cryptobyte`, `hkdf`, `chacha20`, `chacha20poly1305` and `curve25519`
are byte-identical, and those are every x/crypto package go-iroh's TLS
path imports. The transitive bumps in that go.mod do not move this
build either - tidying after the downgrade changed two lines of go.sum.
So there is no mechanism by which this version could reject a peer's
raw public key.

**And the bisect was not sound.** fleet-e2e has failed 8 times in 40
runs on this branch - 20% - so one red run against one green run is not
evidence, which is a rule I hold and did not apply. Retracted.

What is true, and is the part worth keeping: today's symptom is novel.
Every earlier failure was "both workers did not join", a rendezvous
timing flake. `CRYPTO_ERROR 0x12a` - bad_certificate on the blob
endpoint - has never appeared before. That deserves an explanation and
has not got one yet.

So the pin goes, and the next run on v0.57.0 is a real second data
point rather than a confirmation of a guess.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PQ2c9xLfQvSY8YGLr7o5tv
fleet-e2e has failed 8 times in 40 runs on this branch - 20% - and seven
of those were "both workers did not join". That flakiness is not a
nuisance: it is why today's novel `CRYPTO_ERROR` could not be read as
signal, because one red run against a 20% base rate says nothing.

The cause is an asymmetry between two numbers that were never related.
A driver waits `EARTH_FLEET_WAIT` for workers - 8 minutes in CI. A
worker waited `DefaultPatience`, a constant 2 minutes, for the driver.
Nothing tied them together, and the driver is systematically the slower
side to appear, because it builds the engine *and* the guest before it
can listen.

Run 7178389 is the shape in full: the worker announced itself at
08:25:31 and gave up around 08:27:31; the driver began listening at
08:29:37 and waited, alone, until 08:37:37. Two processes waiting for
each other, four minutes apart, neither at fault.

So a worker now takes its patience from the driver's own declared
willingness to wait, never shortening below the old default, and
`EARTH_FLEET_WAIT` moves to workflow scope so both sides read one
number. A fleet with nothing configured is unchanged.

`DefaultPatience`'s own comment already said "a worker started before
its driver is the normal case in CI, where the jobs start together" -
the observation was right and the constant beside it was two minutes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PQ2c9xLfQvSY8YGLr7o5tv
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ai-assisted Authored with AI assistance

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants