This is an automated email from the ASF dual-hosted git repository.
vatsrahul1001 pushed a commit to branch v3-4-test
in repository https://gitbox.apache.org/repos/asf/airflow.git
The following commit(s) were added to refs/heads/v3-4-test by this push:
new 295ec25fcf2 [v3-4-test] Cover YAML-generated Go Dags in the go_sdk e2e
test (#74636) (#74824)
295ec25fcf2 is described below
commit 295ec25fcf2653a303846673becf1d41343c6fad
Author: github-actions[bot]
<41898282+github-actions[bot]@users.noreply.github.com>
AuthorDate: Sun Oct 18 17:15:33 2026 +0530
[v3-4-test] Cover YAML-generated Go Dags in the go_sdk e2e test (#74636)
(#74824)
* Go SDK: add an example that generates Dags from a YAML file
* Cover YAML-generated Go Dags in the go_sdk e2e test
---------
(cherry picked from commit a3c8ee41d04e1191f2a9c7394ac865d21415f039)
Co-authored-by: Jason(Zhe-You) Liu
<[email protected]>
---
airflow-e2e-tests/docker/go.yml | 5 +
.../tests/airflow_e2e_tests/conftest.py | 159 ++++++++++++++-------
.../tests/airflow_e2e_tests/constants.py | 4 +
.../airflow_e2e_tests/e2e_test_utils/clients.py | 32 +++++
.../go_sdk_tests/test_go_sdk_dynamic_dag.py | 110 ++++++++++++++
go-sdk/example/dynamic/dags.go | 67 +++++++++
go-sdk/example/dynamic/dags.yaml | 24 ++++
go-sdk/example/dynamic/main.go | 60 ++++++++
8 files changed, 407 insertions(+), 54 deletions(-)
diff --git a/airflow-e2e-tests/docker/go.yml b/airflow-e2e-tests/docker/go.yml
index 38fde7634d2..55e4bb39f12 100644
--- a/airflow-e2e-tests/docker/go.yml
+++ b/airflow-e2e-tests/docker/go.yml
@@ -30,6 +30,11 @@ services:
environment:
USER: airflow
HOME: /home/airflow
+ # The dynamic Dag test swaps a YAML file and waits for the Dag IDs to
change. Re-parse the
+ # bundle every few seconds, and mark a Dag stale soon after it leaves
the YAML.
+ AIRFLOW__DAG_PROCESSOR__MIN_FILE_PROCESS_INTERVAL: '5'
+ AIRFLOW__DAG_PROCESSOR__STALE_DAG_THRESHOLD: '10'
+ AIRFLOW__SCHEDULER__PARSING_CLEANUP_INTERVAL: '5'
airflow-worker:
# The bundle is built with CGO_ENABLED=0, so the SDK's user.Current() call
at
diff --git a/airflow-e2e-tests/tests/airflow_e2e_tests/conftest.py
b/airflow-e2e-tests/tests/airflow_e2e_tests/conftest.py
index 38ea7bd8141..e9d65166c6f 100644
--- a/airflow-e2e-tests/tests/airflow_e2e_tests/conftest.py
+++ b/airflow-e2e-tests/tests/airflow_e2e_tests/conftest.py
@@ -43,6 +43,9 @@ from airflow_e2e_tests.constants import (
GO_SDK_BIN_PATH,
GO_SDK_BUNDLE_NAME,
GO_SDK_DAGS_PATH,
+ GO_SDK_DYNAMIC_BUNDLE_NAME,
+ GO_SDK_DYNAMIC_BUNDLE_PKG,
+ GO_SDK_DYNAMIC_YAML_PATH,
GO_SDK_EXAMPLE_BUNDLE_PKG,
GO_SDK_NATIVE_BUNDLE_NAME,
GO_SDK_NATIVE_BUNDLE_PKG,
@@ -567,13 +570,12 @@ def _setup_java_sdk_integration(dot_env_file, tmp_dir):
_write_state_store_retention_config(tmp_dir)
-def _run_go_sdk_pack(package, output_path, *, capture_output=False,
native=False):
- """Run ``go tool airflow-go-pack`` natively or inside the pinned Go
toolchain container.
+def _run_go(args, *, capture_output=False, native=False):
+ """Run ``go <args>`` in the go-sdk module, on the host or inside the
pinned Go toolchain container.
- ``go tool airflow-go-pack`` builds the bundle *package*, reads its
- --airflow-metadata, and appends the source + airflow-metadata.yaml + the
- AFBNDL01 trailer, writing a single self-contained executable bundle.
- CGO_ENABLED=0 yields a fully static binary that runs on the stock worker.
+ A ``Path`` in *args* is a host path; in the container it is mapped to
where the repo is mounted.
+ Other strings, such as ``./example/dynamic``, are passed as they are and
resolve against go-sdk.
+ CGO_ENABLED=0 yields fully static binaries that run on the stock worker.
In ``native`` mode (used in CI, where the host already has a Go toolchain
plus
restored module/build caches via ``actions/setup-go``) it invokes the host
``go``
@@ -592,64 +594,102 @@ def _run_go_sdk_pack(package, output_path, *,
capture_output=False, native=False
go.yml so the packed binary runs the same way at execution time).
"""
if native:
- cwd = GO_SDK_ROOT_PATH
- env = {**os.environ, "CGO_ENABLED": "0"}
- argv = [
- "go",
- "tool",
- "airflow-go-pack",
- "--output",
- str(output_path),
- package,
- ]
- else:
- cwd = None
- env = None
- # Mount the repo so the whole go-sdk module (go.mod, tool directive,
- # example sources) is visible to `go tool`.
- container_go_sdk_dir =
f"/repo/{GO_SDK_ROOT_PATH.relative_to(AIRFLOW_ROOT_PATH)}"
- container_bin_dir =
f"/repo/{GO_SDK_BIN_PATH.relative_to(AIRFLOW_ROOT_PATH)}"
- argv = [
- "docker",
- "run",
- "--rm",
- "--user",
- f"{os.getuid()}:{os.getgid()}",
- "-e",
- f"HOME={container_bin_dir}/.home",
- "-e",
- "USER=airflow",
- "-e",
- "CGO_ENABLED=0",
- "-v",
- f"{AIRFLOW_ROOT_PATH}:/repo",
- "-w",
- container_go_sdk_dir,
- GO_BUILDER_IMAGE,
- "go",
- "tool",
- "airflow-go-pack",
- "--output",
- f"{container_bin_dir}/{output_path.name}",
- package,
- ]
- return subprocess.run(argv, cwd=cwd, env=env, check=True,
capture_output=capture_output, text=True)
+ argv = ["go", *(str(arg) for arg in args)]
+ return subprocess.run(
+ argv,
+ cwd=GO_SDK_ROOT_PATH,
+ env={**os.environ, "CGO_ENABLED": "0"},
+ check=True,
+ capture_output=capture_output,
+ text=True,
+ )
+ # Mount the repo so the whole go-sdk module (go.mod, tool directive,
+ # example sources) is visible to `go`.
+ container_bin_dir = _get_container_repo_path(GO_SDK_BIN_PATH)
+ argv = [
+ "docker",
+ "run",
+ "--rm",
+ "--user",
+ f"{os.getuid()}:{os.getgid()}",
+ "-e",
+ f"HOME={container_bin_dir}/.home",
+ "-e",
+ "USER=airflow",
+ "-e",
+ "CGO_ENABLED=0",
+ "-v",
+ f"{AIRFLOW_ROOT_PATH}:/repo",
+ "-w",
+ _get_container_repo_path(GO_SDK_ROOT_PATH),
+ GO_BUILDER_IMAGE,
+ "go",
+ *(_get_container_repo_path(arg) if isinstance(arg, Path) else str(arg)
for arg in args),
+ ]
+ return subprocess.run(argv, check=True, capture_output=capture_output,
text=True)
-def _pack_go_sdk_bundle(package, name, *, native=False):
- """Build a Go SDK bundle, capturing output so a failure prints the build
log."""
- output_path = GO_SDK_BIN_PATH / name
+
+def _get_container_repo_path(host_path):
+ """Map a host path under the repo to where ``_run_go`` mounts the repo in
the container."""
+ return f"/repo/{host_path.relative_to(AIRFLOW_ROOT_PATH)}"
+
+
+def _run_go_logged(description, args, *, native=False):
+ """Run ``go <args>`` with ``_run_go``, printing its output, or the output
of a failure."""
mode_label = "host toolchain" if native else GO_BUILDER_IMAGE
- console.print(f"[yellow]Building Go SDK bundle {package}
({mode_label})...")
+ console.print(f"[yellow]{description} ({mode_label})...")
try:
- completed = _run_go_sdk_pack(package, output_path,
capture_output=True, native=native)
+ completed = _run_go(args, capture_output=True, native=native)
except subprocess.CalledProcessError as e:
- console.print(f"[red]Go SDK bundle {package} build failed:")
+ console.print(f"[red]{description} failed:")
console.print(e.stdout, e.stderr, sep="\n", markup=False,
soft_wrap=True)
raise
console.print(completed.stdout, completed.stderr, sep="\n", markup=False,
soft_wrap=True)
+def _pack_go_sdk_bundle(package, name, *, native=False):
+ """Build a Go SDK *package* into a self-contained bundle with ``go tool
airflow-go-pack``."""
+ _run_go_logged(
+ f"Building Go SDK bundle {package}",
+ ["tool", "airflow-go-pack", "--output", GO_SDK_BIN_PATH / name,
package],
+ native=native,
+ )
+
+
+def _pack_go_sdk_dynamic_bundle(*, native=False):
+ """Build and pack the dynamic example bundle, which generates its Dags
from a dags.yaml.
+
+ The packer reads the Dag IDs by running the binary, and the binary reads
dags.yaml next to
+ itself. So build the binary, put dags.yaml beside it, then pack that
binary in place with
+ ``--executable``. The packer needs ``--source`` there, as it cannot find
the entrypoint of a
+ pre-built binary.
+ """
+ bin_dir = GO_SDK_BIN_PATH / "dynamic"
+ bin_dir.mkdir(parents=True, exist_ok=True)
+ raw_binary = bin_dir / f"{GO_SDK_DYNAMIC_BUNDLE_NAME}.raw"
+ _run_go_logged(
+ f"Building Go SDK binary {GO_SDK_DYNAMIC_BUNDLE_PKG}",
+ ["build", "-o", raw_binary, GO_SDK_DYNAMIC_BUNDLE_PKG],
+ native=native,
+ )
+ copyfile(GO_SDK_DYNAMIC_YAML_PATH, bin_dir / "dags.yaml")
+ _run_go_logged(
+ f"Packing Go SDK bundle {GO_SDK_DYNAMIC_BUNDLE_PKG}",
+ [
+ "tool",
+ "airflow-go-pack",
+ "--executable",
+ raw_binary,
+ "--source",
+ f"{GO_SDK_DYNAMIC_BUNDLE_PKG}/main.go",
+ "--output",
+ GO_SDK_BIN_PATH / GO_SDK_DYNAMIC_BUNDLE_NAME,
+ ],
+ native=native,
+ )
+
+
def _setup_go_sdk_integration(dot_env_file, tmp_dir):
"""Set up the go_sdk E2E test mode.
@@ -662,12 +702,16 @@ def _setup_go_sdk_integration(dot_env_file, tmp_dir):
processor runs it to parse its Dags. Its tasks run on the ``golang`` queue
through the same coordinator.
+ The dynamic example goes into the Dags folder as well, with the
``dags.yaml`` it
+ generates its Dags from next to it, so a test can swap the YAML to change
the Dags.
+
The packed bundle is a statically linked native executable (built with
``CGO_ENABLED=0``), so the stock Airflow worker image can exec it directly
without a Go toolchain or any extra runtime installed -- see ``go.yml``.
"""
_pack_go_sdk_bundle(GO_SDK_EXAMPLE_BUNDLE_PKG, GO_SDK_BUNDLE_NAME,
native=LANG_SDK_NATIVE_TOOLCHAIN)
_pack_go_sdk_bundle(GO_SDK_NATIVE_BUNDLE_PKG, GO_SDK_NATIVE_BUNDLE_NAME,
native=LANG_SDK_NATIVE_TOOLCHAIN)
+ _pack_go_sdk_dynamic_bundle(native=LANG_SDK_NATIVE_TOOLCHAIN)
# Copy the compose override into the temp directory.
copyfile(GO_COMPOSE_PATH, tmp_dir / "go.yml")
@@ -691,6 +735,13 @@ def _setup_go_sdk_integration(dot_env_file, tmp_dir):
copyfile(GO_SDK_BIN_PATH / GO_SDK_NATIVE_BUNDLE_NAME, packed_native_bundle)
os.chmod(packed_native_bundle, 0o755)
+ dynamic_dags_dir = tmp_dir / "dags" / "go-dynamic"
+ dynamic_dags_dir.mkdir()
+ packed_dynamic_bundle = dynamic_dags_dir / GO_SDK_DYNAMIC_BUNDLE_NAME
+ copyfile(GO_SDK_BIN_PATH / GO_SDK_DYNAMIC_BUNDLE_NAME,
packed_dynamic_bundle)
+ os.chmod(packed_dynamic_bundle, 0o755)
+ copyfile(GO_SDK_DYNAMIC_YAML_PATH, dynamic_dags_dir / "dags.yaml")
+
# Copy the Go SDK example stub Dag so Airflow can discover and serialize
it.
copyfile(GO_SDK_DAGS_PATH / "go_examples.py", tmp_dir / "dags" /
"go_examples.py")
diff --git a/airflow-e2e-tests/tests/airflow_e2e_tests/constants.py
b/airflow-e2e-tests/tests/airflow_e2e_tests/constants.py
index 7fd1323cc3f..40de0c37ffc 100644
--- a/airflow-e2e-tests/tests/airflow_e2e_tests/constants.py
+++ b/airflow-e2e-tests/tests/airflow_e2e_tests/constants.py
@@ -103,6 +103,10 @@ GO_SDK_BUNDLE_NAME = "example_dags"
# The native-Dag example: Dags declared in Go and parsed from the binary by
the Dag processor.
GO_SDK_NATIVE_BUNDLE_PKG = "./example/native"
GO_SDK_NATIVE_BUNDLE_NAME = "go_native_dags"
+# The dynamic example: Dags generated from a dags.yaml shipped next to the
packed binary.
+GO_SDK_DYNAMIC_BUNDLE_PKG = "./example/dynamic"
+GO_SDK_DYNAMIC_BUNDLE_NAME = "go_dynamic_dags"
+GO_SDK_DYNAMIC_YAML_PATH = GO_SDK_ROOT_PATH / "example" / "dynamic" /
"dags.yaml"
# Where airflow-go-pack writes the packed bundle inside the repo (go-sdk/bin
is gitignored).
GO_SDK_BIN_PATH = GO_SDK_ROOT_PATH / "bin"
GO_COMPOSE_PATH = AIRFLOW_ROOT_PATH / "airflow-e2e-tests" / "docker" / "go.yml"
diff --git
a/airflow-e2e-tests/tests/airflow_e2e_tests/e2e_test_utils/clients.py
b/airflow-e2e-tests/tests/airflow_e2e_tests/e2e_test_utils/clients.py
index 1f1d1ad435a..821765ad212 100644
--- a/airflow-e2e-tests/tests/airflow_e2e_tests/e2e_test_utils/clients.py
+++ b/airflow-e2e-tests/tests/airflow_e2e_tests/e2e_test_utils/clients.py
@@ -125,6 +125,38 @@ class AirflowClient:
time.sleep(check_interval)
raise TimeoutError(f"Dag {dag_id} was not registered within
{timeout}s. Last error: {last_error}")
+ def list_dag_ids(self, tags: list[str]) -> set[str]:
+ """List the IDs of the live Dags with any of *tags*; the API leaves
out stale Dags by default."""
+ dag_ids: set[str] = set()
+ limit = 100
+ offset = 0
+ while True:
+ resp = self._make_request(
+ method="GET", endpoint="dags", params={"tags": tags, "limit":
limit, "offset": offset}
+ )
+ dag_ids |= {dag["dag_id"] for dag in resp["dags"]}
+ offset += len(resp["dags"])
+ if not resp["dags"] or offset >= resp["total_entries"]:
+ return dag_ids
+
+ def wait_for_dag_ids(
+ self, expected: set[str], tags: list[str], timeout: int = 120,
check_interval: int = 3
+ ):
+ """Poll until the live Dags with *tags* are exactly *expected*.
+
+ Raises ``TimeoutError`` naming the expected and the last seen Dag IDs
after *timeout* seconds.
+ """
+ start = time.monotonic()
+ seen: set[str] = set()
+ while time.monotonic() - start < timeout:
+ seen = self.list_dag_ids(tags)
+ if seen == expected:
+ return
+ time.sleep(check_interval)
+ raise TimeoutError(
+ f"Dag IDs did not become {sorted(expected)} within {timeout}s.
Last seen: {sorted(seen)}"
+ )
+
def un_pause_dag(self, dag_id: str):
self.wait_for_dag(dag_id)
return self._make_request(
diff --git
a/airflow-e2e-tests/tests/airflow_e2e_tests/go_sdk_tests/test_go_sdk_dynamic_dag.py
b/airflow-e2e-tests/tests/airflow_e2e_tests/go_sdk_tests/test_go_sdk_dynamic_dag.py
new file mode 100644
index 00000000000..ca9c3e07269
--- /dev/null
+++
b/airflow-e2e-tests/tests/airflow_e2e_tests/go_sdk_tests/test_go_sdk_dynamic_dag.py
@@ -0,0 +1,110 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+"""E2E test for the Go SDK Dags that ``go-sdk/example/dynamic`` generates from
a YAML file.
+
+Run with::
+
+ E2E_TEST_MODE=go_sdk uv run --project airflow-e2e-tests pytest \\
+ tests/airflow_e2e_tests/go_sdk_tests/test_go_sdk_dynamic_dag.py -xvs
+
+``conftest._setup_go_sdk_integration`` puts the packed binary and its
``dags.yaml`` into the Dags
+folder. The binary generates one Dag for each entry of the YAML, so the test
swaps only the YAML,
+with no rebuild, and checks that the live Dag IDs follow it and that each live
Dag runs.
+"""
+
+from __future__ import annotations
+
+import os
+from datetime import UTC, datetime
+from pathlib import Path
+
+import yaml
+
+from airflow_e2e_tests.e2e_test_utils.clients import AirflowClient
+
+# The Dag processor execs the binary to parse it, so the Dags appear some time
after the stack is up.
+_PARSE_TIMEOUT = 600
+# After that, the Dag processor re-parses every few seconds (see go.yml), and
a Dag that left the
+# YAML goes stale a few seconds after that.
+_RELOAD_TIMEOUT = 180
+_GO_TASK_TIMEOUT = 600
+
+_TAGS = ["go_dynamic"]
+
+
+def _write_yaml_atomically(path: Path, greetings: dict[str, str]) -> None:
+ """Replace the YAML in one step, so the Dag processor never reads half of
it."""
+ entries = [{"dag_id": dag_id, "greeting": greeting} for dag_id, greeting
in greetings.items()]
+ tmp_path = path.with_name(f"{path.name}.tmp")
+ tmp_path.write_text(yaml.safe_dump({"dags": entries}))
+ os.replace(tmp_path, path)
+
+
+def _swap_yaml_and_check(
+ client: AirflowClient, yaml_path: Path, greetings: dict[str, str],
previous: set[str], timeout: int
+) -> set[str]:
+ """Swap in a YAML of *greetings*, then check the Dags, a run of each, and
the Dags it dropped."""
+ _write_yaml_atomically(yaml_path, greetings)
+ client.wait_for_dag_ids(set(greetings), tags=_TAGS, timeout=timeout)
+
+ run_ids = {
+ dag_id: client.trigger_dag(dag_id, json={"logical_date":
datetime.now(UTC).isoformat()})["dag_run_id"]
+ for dag_id in greetings
+ }
+ for dag_id, run_id in run_ids.items():
+ state = client.wait_for_dag_run(dag_id=dag_id, run_id=run_id,
timeout=_GO_TASK_TIMEOUT)
+ assert state == "success", f"{dag_id} run {run_id} ended {state!r}"
+ xcom = client.get_xcom_value(dag_id=dag_id, task_id="greet",
run_id=run_id, key="return_value")
+ assert xcom["value"] == greetings[dag_id], f"{dag_id} returned
{xcom['value']!r}"
+
+ for dag_id in previous - set(greetings):
+ assert client.get_dag(dag_id)["is_stale"], f"{dag_id} left the YAML
but is not stale"
+ return set(greetings)
+
+
+def test_swapping_the_yaml_changes_the_live_dags(airflow_dags_path: Path):
+ client = AirflowClient()
+ yaml_path = airflow_dags_path / "go-dynamic" / "dags.yaml"
+
+ live = _swap_yaml_and_check(
+ client,
+ yaml_path,
+ {"go_dynamic_a": "hello from a", "go_dynamic_b": "hello from b",
"go_dynamic_c": "hello from c"},
+ previous=set(),
+ timeout=_PARSE_TIMEOUT,
+ )
+ live = _swap_yaml_and_check(
+ client,
+ yaml_path,
+ {
+ "go_dynamic_a": "hello from a",
+ "go_dynamic_b": "hello from b",
+ "go_dynamic_c": "hello from c",
+ "go_dynamic_d": "hello from d",
+ "go_dynamic_e": "hello from e",
+ },
+ previous=live,
+ timeout=_RELOAD_TIMEOUT,
+ )
+ # a keeps its Dag ID but gets a new greeting, so its run shows the Dag was
rebuilt from the new YAML.
+ _swap_yaml_and_check(
+ client,
+ yaml_path,
+ {"go_dynamic_a": "hello again from a", "go_dynamic_f": "hello from f"},
+ previous=live,
+ timeout=_RELOAD_TIMEOUT,
+ )
diff --git a/go-sdk/example/dynamic/dags.go b/go-sdk/example/dynamic/dags.go
new file mode 100644
index 00000000000..c83fe06e722
--- /dev/null
+++ b/go-sdk/example/dynamic/dags.go
@@ -0,0 +1,67 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+package main
+
+import (
+ "fmt"
+ "os"
+
+ "gopkg.in/yaml.v3"
+
+ "github.com/apache/airflow/go-sdk/airflow"
+)
+
+type dagsFile struct {
+ Dags []dagEntry `yaml:"dags"`
+}
+
+type dagEntry struct {
+ DagID string `yaml:"dag_id"`
+ Greeting string `yaml:"greeting"`
+}
+
+// loadDags builds one Dag for each entry of the YAML file at path.
+func loadDags(path string) ([]airflow.Registerable, error) {
+ data, err := os.ReadFile(path)
+ if err != nil {
+ return nil, err
+ }
+ var file dagsFile
+ if err := yaml.Unmarshal(data, &file); err != nil {
+ return nil, fmt.Errorf("parsing %s: %w", path, err)
+ }
+
+ dags := make([]airflow.Registerable, 0, len(file.Dags))
+ for _, entry := range file.Dags {
+ dag := airflow.Dag(entry.DagID, airflow.DagSpec{
+ Queue: "golang",
+ Tags: []string{"go", "go_dynamic"},
+ })
+ dag.Task(
+ greet,
+ airflow.TaskSpec{TaskID: "greet"},
+ airflow.Inputs(airflow.Literal(entry.Greeting)),
+ )
+ dags = append(dags, dag)
+ }
+ return dags, nil
+}
+
+func greet(_ airflow.Context, greeting string) (string, error) {
+ return greeting, nil
+}
diff --git a/go-sdk/example/dynamic/dags.yaml b/go-sdk/example/dynamic/dags.yaml
new file mode 100644
index 00000000000..5c59cb8d49a
--- /dev/null
+++ b/go-sdk/example/dynamic/dags.yaml
@@ -0,0 +1,24 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+---
+dags:
+ - dag_id: go_dynamic_a
+ greeting: hello from a
+ - dag_id: go_dynamic_b
+ greeting: hello from b
+ - dag_id: go_dynamic_c
+ greeting: hello from c
diff --git a/go-sdk/example/dynamic/main.go b/go-sdk/example/dynamic/main.go
new file mode 100644
index 00000000000..8287de26c0e
--- /dev/null
+++ b/go-sdk/example/dynamic/main.go
@@ -0,0 +1,60 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+// Package main is an example of a Dag definition that generates its Dags from
a dags.yaml file
+// next to the packed binary.
+//
+// The packer reads the Dag IDs by running the binary, so dags.yaml must sit
next to the binary
+// when it packs. Build the binary, put dags.yaml next to it, and pack it in
place with
+// --executable, which also needs --source:
+//
+// go build -o ./bin/dynamic/go_dynamic_dags.raw ./example/dynamic
+// cp ./example/dynamic/dags.yaml ./bin/dynamic/dags.yaml
+// go tool airflow-go-pack --executable ./bin/dynamic/go_dynamic_dags.raw \
+// --source ./example/dynamic/main.go --output ./bin/go_dynamic_dags
+//
+// Put the packed bundle and dags.yaml together in a Dag bundle. Swapping only
dags.yaml there
+// changes the Dags with no rebuild. Without a dags.yaml next to it, the
bundle registers no Dags.
+package main
+
+import (
+ "errors"
+ "io/fs"
+ "log"
+ "os"
+ "path/filepath"
+
+ "github.com/apache/airflow/go-sdk/airflow"
+)
+
+func main() {
+ exe, err := os.Executable()
+ if err != nil {
+ log.Fatal(err)
+ }
+ dags, err := loadDags(filepath.Join(filepath.Dir(exe), "dags.yaml"))
+ if err != nil && !errors.Is(err, fs.ErrNotExist) {
+ log.Fatal(err)
+ }
+
+ bundle := airflow.Bundle()
+ bundle.Register(dags...)
+
+ if err := bundle.Serve(); err != nil {
+ log.Fatal(err)
+ }
+}