go-sdk/adr/0001-bundle-packing-options.md
Date: 2026-04-30
Accepted as the option register. The packer-mechanism decision is
recorded in ADR 0002:
Option H (Go 1.24 tool directive) for delivery, paired with Option A
(standalone airflow-go-pack binary) and Option D (standardised
--airflow-metadata introspection contract — the single metadata flag
that prints the bundle's airflow-metadata.yaml spec as JSON, which
airflow-go-pack reads to populate the manifest). The shipped runtime
(bundlev1server.Serve)
routes through a decideMode switch with three modes —
--airflow-metadata, --comm/--logs (coordinator mode), and the
default go-plugin path.
The container-format assumption running through this ADR — that the output is a ZIP archive — is superseded by ADR 0004, which embeds the source and manifest in a footer appended to the executable. The options below still describe valid packer mechanisms; only the artefact each one writes has changed from a ZIP to a footer-augmented executable.
The executable provider's bundle spec
(task-sdk/docs/executable-bundle-spec.rst)
defined the deployment artifact, at the time this ADR was written, as a
ZIP archive containing:
airflow-metadata.yaml declaring airflow_bundle_metadata_version, sdk
(language, version, supervisor_schema_version), source
(archive-relative path to the DAG source file), executable
(archive-relative path to the compiled binary), and dags (a mapping of
dag_id to {tasks: [task_id, ...]}). The shipped spec replaced the ZIP
with a footer-augmented executable (see
ADR 0004): it dropped the
executable field (the binary is the file) and redefined source as a
display filename rather than an archive-relative path. The manifest keys
above are otherwise unchanged.--comm=<addr> / --logs=<addr>).Bundle authors today produce the executable with a plain go build
(see go-sdk/example/bundle/Justfile). There is
no SDK-provided way to produce the conforming ZIP, so each author would need
to hand-roll one.
The bundle binary already exposes a --airflow-metadata flag (defined in
bundle/bundlev1/bundlev1server/server.go)
that prints the BundleInfo{Name, Version} returned by the author's
BundleProvider.GetBundleVersion(). It does not currently invoke
RegisterDags, so it does not yet enumerate dag_id / task_id for the
manifest. This is relevant context: the binary itself is the authoritative
source of dag/task identity at runtime, and the SDK can extend the
introspection path cheaply.
The user's initial framing was go build -toolexec. -toolexec wraps each
toolchain invocation (compile, asm, link) and does not have visibility into
the final -o output path or a single "build finished" hook, so it is a poor
fit for producing the final ZIP. The options below cover the mechanisms that
do fit, plus the -toolexec variant for completeness.
A packing mechanism has two sub-decisions:
RegisterDags against an in-memory
registry recorder), static AST scan of the source file, or
hand-written manifest.The options below combine those two sub-decisions in different ways.
airflow-go-pack)A new binary under go-sdk/cmd/airflow-go-pack that takes
already-built inputs and writes the ZIP:
airflow-go-pack \
--source ./example/bundle/main.go \
--executable ./bin/example-dag-bundle \
--output ./bin/example.zip
Manifest population: the packer execs the supplied executable with
--airflow-metadata and reads the JSON from stdout to fill sdk
(language, version, supervisor_schema_version) and to enumerate
dags. Source language is hard-coded to go; SDK version is read from the
build info embedded in the binary or from a build-time -ldflags value.
-ldflags); no
coupling to go build invocation; trivially callable from just,
make, CI, or go generate.go build then airflow-go-pack); user has to
install or go run the tool; nothing prevents pack/build mismatch
(e.g. packing yesterday's binary).build subcommandA single SDK CLI (airflow-go) with subcommands that wrap go build and
then pack:
airflow-go build ./example/bundle --output ./bin/example.zip
Internally: spawn go build -o <tmp>/bundle <pkg>, then run the same
introspection step as Option A, then write the ZIP.
airflow-go new, airflow-go run,
airflow-go validate); good defaults for -ldflags (e.g.
-X main.bundleVersion=...) without the author having to know them.go build wrapper and inherits
responsibility for forwarding the long tail of go build flags
(-tags, -trimpath, GOOS/GOARCH env, -ldflags passthrough,
-buildvcs, etc.); harder to integrate with non-trivial existing build
systems that already drive go build themselves.--pack-bundle <out.zip>)Extend bundlev1server.Serve so that when the binary is invoked with
--pack-bundle <out.zip>, it builds the ZIP itself: it knows its own
executable path (os.Executable()), its embedded source (via //go:embed
of the DAG source file at build time), and its dag/task list (by
calling RegisterDags against an in-memory recorder). After writing
the archive, it exits.
main package to embed its own source
(//go:embed main.go or similar), which is awkward when the DAG is
spread across multiple files or the source path is non-obvious;
bloats every bundle binary with packing code and an embedded copy of
the source; mixes build-time concerns into a runtime entrypoint.Same shape as Option A or B, but standardise the introspection contract:
the SDK guarantees that every bundle binary supports a single
--airflow-metadata flag which prints a JSON blob containing
sdk.language, sdk.version, sdk.supervisor_schema_version, and the
full dags mapping. The packer's only job is to combine that JSON, the
source file path the user passes in, and the binary itself into a bundle.
This is really a refinement of A/B that fixes the introspection contract
in the SDK protocol, rather than an independent option, but is worth
calling out because the shape of the introspection flag is itself a
decision (single flag vs. several; JSON vs. YAML; pretty vs. compact).
The SDK settles that sub-decision in favour of a single
--airflow-metadata flag.
--executable paths that hand the packer a pre-built cross-target
binary) force the packer to produce a host-arch sidecar purely to
run --airflow-metadata. See ADR 0002
for the pipeline.Parser-only packer: walk the DAG source AST, find dagbag.AddDag("X")
calls and the .AddTask(fn) calls chained off them, and synthesise the
manifest without running the binary.
for _, name := range names { dagbag.AddDag(name) }, helper functions, generated code); the SDK
becomes the second source of truth for dag/task identity, which can
drift from RegisterDags; users will hit "I added a DAG and the
packer didn't see it" failures.go generate directiveDocument a recommended //go:generate line in the author's main.go:
//go:generate go run github.com/apache/airflow/go-sdk/cmd/airflow-go-pack ...
go generate is then the build-time entrypoint.
go run fetches the packer from the module cache); discoverable from
the source file itself.go generate has to be run explicitly; users frequently
forget; doesn't actually pack the binary, only triggers a tool that
must still build and pack it; fits awkwardly because the natural
ordering is pack -> build, not build -> pack.go build -toolexec wrapperProvide a binary that the user passes as
go build -toolexec=airflow-go-toolexec .... The wrapper proxies every
toolchain call and, on detecting the final link invocation, copies the
linker's output path, then runs the packing step against it.
go build invocation; nominally fits into existing
go build workflows.-toolexec was not designed for this. It receives the
toolchain executable path and an argv that varies across Go versions;
the wrapper has to parse the linker's -o to discover the binary
location, which is undocumented/internal behaviour and changes
between releases. It also runs once per package compile, so the
wrapper must distinguish "real" link invocations from intermediate
ones. Operationally fragile; recommended against.go.mod tool directive (Go 1.24+)Register the packer in the consuming project's go.mod via the
tool directive and invoke it as go tool airflow-go-pack. This is
orthogonal to A/B/D (it's a delivery mechanism, not a different
implementation) but is worth listing because it changes the install
story significantly.
uv tool-style global install;
works the same in every checkout; aligns with breeze's direction
(ADR 0017
for the Python side).go.mod.Ship a documented Justfile / Makefile / Taskfile snippet that
sequences go build -> zip -> manifest write, and let users copy it
into their projects. The SDK provides only the spec and an example.
These apply to whichever top-level option is chosen:
RegisterDags. Everything else
trades that guarantee for some other property (no binary needed,
no extra flag, etc.).source field and the file itself to
be present in the ZIP. The packer needs either (a) an explicit
--source argument, (b) a convention (e.g. the main.go of the
main package being built), or (c) a //go:embed-d copy inside
the binary (Option C).runtime/debug.ReadBuildInfo
walking the deps for github.com/apache/airflow/go-sdk, or from
a build-time -ldflags -X value the SDK documents.0755 (or similar) on the executable entry; this
is trivial in Go's archive/zip but easy to get wrong in shell.Recorded in ADR 0002.
Summary: deliver the packer via the Go 1.24 tool directive (Option H);
implement it as a standalone binary at cmd/airflow-go-pack (Option A);
populate the manifest by execing the bundle binary with the standardised
--airflow-metadata introspection flag (Option D).
Listing the options here, rather than only landing the chosen one,
keeps the rejected alternatives discoverable for future SDKs (Rust,
C++, Zig) which will face the same question, and documents why
-toolexec and AST-only scanning were considered and dropped.