From 0401a72079ab5640d99e3fc63db47c4deb10181a Mon Sep 17 00:00:00 2001 From: panos Date: Wed, 2 Sep 2026 17:39:48 +0800 Subject: [PATCH 1/2] fix(devnet): keep the ha-el reth overrides out of the plain devnet docker-compose-reth.yml carried reth overrides for ha-el-0/1/2 on the assumption that compose would ignore them unless docker-compose-cluster.yml was layered in as well. It does not: compose creates any service a later -f file names, whether or not an earlier file declared it. So `make devnet-up-reth` also started three ha-el-* containers, which only got the -f file's own ha-nodekey mount and none of the /genesis.json or /jwt-secret.txt mounts that live in the cluster file. All three died immediately with error: Invalid value '/genesis.json' for --chain : No such file or directory Move those three service overrides into docker-compose-cluster-reth.yml and pass it only when both --cluster and reth are asked for, so the plain reth devnet now resolves to exactly the geth devnet's service list. The cluster path is unchanged: ha-el-* still come up on the reth image and entrypoint with the cluster's mounts intact. --- Makefile | 9 +- ops/README.md | 15 ++- ops/devnet-morph/devnet/__init__.py | 12 ++- ops/devnet-morph/tests/test_devnet_config.py | 45 ++++++++- ops/docker/docker-compose-cluster-reth.yml | 97 ++++++++++++++++++++ ops/docker/docker-compose-reth.yml | 81 +--------------- 6 files changed, 170 insertions(+), 89 deletions(-) create mode 100644 ops/docker/docker-compose-cluster-reth.yml diff --git a/Makefile b/Makefile index 173f6e06a..47dd01abf 100644 --- a/Makefile +++ b/Makefile @@ -161,10 +161,10 @@ export MORPH_RETH_RUSTFLAGS export MORPH_RETH_DOCKER_TARGET export MORPH_RETH_ENTRYPOINT DEVNET_COMPOSE_FILES := -f docker-compose-devnet.yml -DEVNET_CLEAN_COMPOSE_FILES := -f docker-compose-devnet.yml -f docker-compose-cluster.yml -f docker-compose-reth.yml +DEVNET_CLEAN_COMPOSE_FILES := -f docker-compose-devnet.yml -f docker-compose-cluster.yml -f docker-compose-reth.yml -f docker-compose-cluster-reth.yml # The cluster topology is layered before the execution-client override, so that -# docker-compose-reth.yml has the last word on the ha-el-* image, entrypoint and +# the reth files have the last word on the ha-el-* image, entrypoint and # command. Swapping these two leaves the cluster nodes running geth even when # reth was asked for, because later -f files win. ifneq ($(DEVNET_CLUSTER_ENABLED),) @@ -175,6 +175,11 @@ ifeq ($(EXECUTION_CLIENT),geth) DEVNET_EXECUTION_DEPS := submodules else ifeq ($(EXECUTION_CLIENT),reth) DEVNET_COMPOSE_FILES += -f docker-compose-reth.yml +# The ha-el-* reth overrides are kept out of docker-compose-reth.yml because +# compose starts any service a later -f file introduces, cluster or not. +ifneq ($(DEVNET_CLUSTER_ENABLED),) +DEVNET_COMPOSE_FILES += -f docker-compose-cluster-reth.yml +endif ifeq ($(MORPH_RETH_BUILD_FROM_SOURCE),true) DEVNET_EXECUTION_DEPS := reth else diff --git a/ops/README.md b/ops/README.md index 183209a39..0994c9e0b 100644 --- a/ops/README.md +++ b/ops/README.md @@ -89,10 +89,17 @@ looking anywhere else. `ha-node` admin API: `9501` / `9601` / `9701`. The execution-layer services are named `el` rather than `geth` because either -client can back them: `docker-compose-cluster.yml` defines them as geth, and -`docker-compose-reth.yml` overrides them to reth. That override only works -because the cluster file is layered *before* the reth file — later `-f` files -win, so the reverse order silently leaves the cluster on geth. +client can back them: `docker-compose-cluster.yml` defines `ha-el-*` as geth, +and `docker-compose-cluster-reth.yml` overrides them to reth. That override only +works because the cluster file is layered *before* the reth files — later `-f` +files win, so the reverse order silently leaves the cluster on geth. + +The `ha-el-*` reth overrides need their own file rather than a section of +`docker-compose-reth.yml`: compose starts every service a later `-f` file +introduces, whether or not an earlier file declared it, so overrides living in +the shared reth file also came up in the non-cluster devnet — without the +`/genesis.json` and `/jwt-secret.txt` mounts that only the cluster file +provides, which made them exit with `Invalid value '/genesis.json' for --chain`. ## Execution-layer peering diff --git a/ops/devnet-morph/devnet/__init__.py b/ops/devnet-morph/devnet/__init__.py index 5d97b2e5d..ef4d056ad 100644 --- a/ops/devnet-morph/devnet/__init__.py +++ b/ops/devnet-morph/devnet/__init__.py @@ -67,13 +67,19 @@ def compose_file_args(execution_client, cluster=False): """Return docker-compose -f flags for the chosen L2 execution client.""" args = ['-f', 'docker-compose-devnet.yml'] # The cluster topology comes before the execution-client override so that - # docker-compose-reth.yml gets the last word on the ha-el-* image, - # entrypoint and command. Later -f files win, so reversing these two leaves - # the cluster nodes on geth even when reth was requested. + # the reth files get the last word on the ha-el-* image, entrypoint and + # command. Later -f files win, so reversing these two leaves the cluster + # nodes on geth even when reth was requested. if cluster: args.extend(['-f', 'docker-compose-cluster.yml']) if execution_client == 'reth': args.extend(['-f', 'docker-compose-reth.yml']) + # The ha-el-* reth overrides sit in their own file: compose starts any + # service a later -f file introduces, so parking them in + # docker-compose-reth.yml started them in the non-cluster devnet too, + # without the mounts that only the cluster file supplies. + if cluster: + args.extend(['-f', 'docker-compose-cluster-reth.yml']) return args diff --git a/ops/devnet-morph/tests/test_devnet_config.py b/ops/devnet-morph/tests/test_devnet_config.py index 1b88cc38c..0c57b104a 100644 --- a/ops/devnet-morph/tests/test_devnet_config.py +++ b/ops/devnet-morph/tests/test_devnet_config.py @@ -43,7 +43,9 @@ def test_devnet_clean_removes_compose_project_volumes(self): makefile = (REPO_ROOT / "Makefile").read_text() self.assertIn( - "DEVNET_CLEAN_COMPOSE_FILES := -f docker-compose-devnet.yml -f docker-compose-cluster.yml -f docker-compose-reth.yml", + "DEVNET_CLEAN_COMPOSE_FILES := -f docker-compose-devnet.yml " + "-f docker-compose-cluster.yml -f docker-compose-reth.yml " + "-f docker-compose-cluster-reth.yml", makefile, ) self.assertIn("docker compose $(DEVNET_CLEAN_COMPOSE_FILES) down --volumes --remove-orphans", makefile) @@ -152,14 +154,47 @@ def test_reth_compose_configures_deterministic_peering(self): for service, key in ( ("morph-el-0", "nodekey0"), ("morph-el-1", "nodekey1"), + ): + self.assertIn(f"{service}:", compose) + self.assertIn(f'"${{PWD}}/{key}:/p2p-secret.key"', compose) + self.assertEqual(compose.count("--p2p-secret-key=/p2p-secret.key"), 2) + self.assertEqual(compose.count("--trusted-peers="), 2) + + def test_reth_compose_leaves_cluster_services_to_the_cluster_reth_file(self): + """compose starts every service a later -f file introduces, even one no + earlier file declared. ha-el-* overrides parked in the shared reth file + therefore came up in the non-cluster devnet as well, missing the + /genesis.json and /jwt-secret.txt mounts that only + docker-compose-cluster.yml supplies, and exited with + "Invalid value '/genesis.json' for --chain".""" + compose = (DOCKER_DIR / "docker-compose-reth.yml").read_text() + + for service in ("ha-el-0", "ha-el-1", "ha-el-2"): + self.assertNotIn(f"{service}:", compose) + for key in ("ha-nodekey0", "ha-nodekey1", "ha-nodekey2"): + self.assertNotIn(key, compose) + + def test_cluster_reth_compose_overrides_the_ha_execution_clients(self): + cluster_reth = DOCKER_DIR / "docker-compose-cluster-reth.yml" + + self.assertTrue(cluster_reth.exists()) + compose = cluster_reth.read_text() + + self.assertIn("${MORPH_RETH_IMAGE:-ghcr.io/morph-l2/morph-reth:latest}", compose) + self.assertIn("${MORPH_RETH_ENTRYPOINT:-/usr/local/bin/morph-reth}", compose) + for service, key in ( ("ha-el-0", "ha-nodekey0"), ("ha-el-1", "ha-nodekey1"), ("ha-el-2", "ha-nodekey2"), ): self.assertIn(f"{service}:", compose) self.assertIn(f'"${{PWD}}/{key}:/p2p-secret.key"', compose) - self.assertEqual(compose.count("--p2p-secret-key=/p2p-secret.key"), 5) - self.assertEqual(compose.count("--trusted-peers="), 5) + self.assertEqual(compose.count("--p2p-secret-key=/p2p-secret.key"), 3) + self.assertEqual(compose.count("--trusted-peers="), 3) + # Nothing here may redefine the plain devnet's execution clients, or + # they would be reconfigured only in cluster mode. + for service in ("morph-el-0:", "morph-el-1:"): + self.assertNotIn(f" {service}", compose) def test_execution_client_keys_have_no_trailing_newline(self): """reth rejects a key file with a trailing newline ("malformed or @@ -209,6 +244,8 @@ def test_compose_file_args_can_enable_cluster_mode(self): sys.path.remove(str(DEVNET_PACKAGE)) self.assertEqual(devnet.compose_file_args("geth"), ["-f", "docker-compose-devnet.yml"]) + # The ha-el-* reth overrides must not reach the non-cluster devnet: + # compose would start those services without the cluster's mounts. self.assertEqual( devnet.compose_file_args("reth"), ["-f", "docker-compose-devnet.yml", "-f", "docker-compose-reth.yml"], @@ -229,6 +266,8 @@ def test_compose_file_args_can_enable_cluster_mode(self): "docker-compose-cluster.yml", "-f", "docker-compose-reth.yml", + "-f", + "docker-compose-cluster-reth.yml", ], ) diff --git a/ops/docker/docker-compose-cluster-reth.yml b/ops/docker/docker-compose-cluster-reth.yml new file mode 100644 index 000000000..354058ef7 --- /dev/null +++ b/ops/docker/docker-compose-cluster-reth.yml @@ -0,0 +1,97 @@ +# reth overrides for the HA cluster's execution clients, layered on top of +# docker-compose-cluster.yml, which defines ha-el-* as geth. +# +# These live in their own file rather than in docker-compose-reth.yml because +# compose creates any service a later -f file introduces, whether or not an +# earlier file declared it. Keeping the ha-el-* overrides in the reth file made +# them appear in the non-cluster devnet too, where they started without the +# /genesis.json and /jwt-secret.txt mounts that only the cluster file provides +# and died with "Invalid value '/genesis.json' for --chain". So this file is +# passed only when both --cluster and reth are asked for. +# +# See docker-compose-reth.yml for why discovery is off, why every node is handed +# a fixed --p2p-secret-key, and how the enodes below were derived. Peering here +# mirrors static-nodes-cluster.json: each ha-el-* dials the other two plus both +# morph-el-*. +# +# The command list is spelled out per service because --trusted-peers takes one +# comma separated value and YAML cannot concatenate an anchor with extra items. + +x-reth-service: &reth-service + image: ${MORPH_RETH_IMAGE:-ghcr.io/morph-l2/morph-reth:latest} + user: "0:0" + entrypoint: + - ${MORPH_RETH_ENTRYPOINT:-/usr/local/bin/morph-reth} + +services: + ha-el-0: + <<: *reth-service + command: + - node + - --chain=/genesis.json + - --datadir=/db + - --http + - --http.addr=0.0.0.0 + - --http.port=8545 + - --http.api=web3,debug,eth,txpool,net,trace,admin,reth + - --ws + - --ws.addr=0.0.0.0 + - --ws.port=8546 + - --ws.api=web3,debug,eth,txpool,net,trace,admin,reth + - --authrpc.addr=0.0.0.0 + - --authrpc.port=8551 + - --authrpc.jwtsecret=/jwt-secret.txt + - --nat=none + - --disable-discovery + - --p2p-secret-key=/p2p-secret.key + - --trusted-peers=enode://73edbabab8727eba67dd5c2964c610decf28ec73e72d5bc395cc5be3c61ebd76cf72ada700b10926c3d2f9e86d64fafb92c6d02deb48c16439040b7c37f86c2d@ha-el-1:30303,enode://4a1a481e53b57ea351a08d945443cdff9cb7911e9121e8240efa99e4214582c0e383a30706349ce1be88b81d0f6344c401823a6624569e20d26880774279c41c@ha-el-2:30303,enode://58e698ea2dd8a76e0cb185d13c1faabf223b60c89fef988c8b89496571056d6c2922109537bb291cd87f2ec09a23ac37d59bde2c7a4885d07b7b641cadff2921@morph-el-0:30303,enode://bd755ce0bc8c06b4444b9013e8d1215a02e2b53f39f746f060c292ba2f6877d7b702374f006a49a7b1506bf1bc027b43824859d081283e6bac97c8600cdf3fee@morph-el-1:30303 + volumes: + - "${PWD}/ha-nodekey0:/p2p-secret.key" + + ha-el-1: + <<: *reth-service + command: + - node + - --chain=/genesis.json + - --datadir=/db + - --http + - --http.addr=0.0.0.0 + - --http.port=8545 + - --http.api=web3,debug,eth,txpool,net,trace,admin,reth + - --ws + - --ws.addr=0.0.0.0 + - --ws.port=8546 + - --ws.api=web3,debug,eth,txpool,net,trace,admin,reth + - --authrpc.addr=0.0.0.0 + - --authrpc.port=8551 + - --authrpc.jwtsecret=/jwt-secret.txt + - --nat=none + - --disable-discovery + - --p2p-secret-key=/p2p-secret.key + - --trusted-peers=enode://343bbe0507fd72946e8d57d5b42630dc905aed7e512945eb2dfd99a0d27bb1c7e84ba621c3408242d713a972a581374ea83a4d134651c373c4f2e8425ba99857@ha-el-0:30303,enode://4a1a481e53b57ea351a08d945443cdff9cb7911e9121e8240efa99e4214582c0e383a30706349ce1be88b81d0f6344c401823a6624569e20d26880774279c41c@ha-el-2:30303,enode://58e698ea2dd8a76e0cb185d13c1faabf223b60c89fef988c8b89496571056d6c2922109537bb291cd87f2ec09a23ac37d59bde2c7a4885d07b7b641cadff2921@morph-el-0:30303,enode://bd755ce0bc8c06b4444b9013e8d1215a02e2b53f39f746f060c292ba2f6877d7b702374f006a49a7b1506bf1bc027b43824859d081283e6bac97c8600cdf3fee@morph-el-1:30303 + volumes: + - "${PWD}/ha-nodekey1:/p2p-secret.key" + + ha-el-2: + <<: *reth-service + command: + - node + - --chain=/genesis.json + - --datadir=/db + - --http + - --http.addr=0.0.0.0 + - --http.port=8545 + - --http.api=web3,debug,eth,txpool,net,trace,admin,reth + - --ws + - --ws.addr=0.0.0.0 + - --ws.port=8546 + - --ws.api=web3,debug,eth,txpool,net,trace,admin,reth + - --authrpc.addr=0.0.0.0 + - --authrpc.port=8551 + - --authrpc.jwtsecret=/jwt-secret.txt + - --nat=none + - --disable-discovery + - --p2p-secret-key=/p2p-secret.key + - --trusted-peers=enode://343bbe0507fd72946e8d57d5b42630dc905aed7e512945eb2dfd99a0d27bb1c7e84ba621c3408242d713a972a581374ea83a4d134651c373c4f2e8425ba99857@ha-el-0:30303,enode://73edbabab8727eba67dd5c2964c610decf28ec73e72d5bc395cc5be3c61ebd76cf72ada700b10926c3d2f9e86d64fafb92c6d02deb48c16439040b7c37f86c2d@ha-el-1:30303,enode://58e698ea2dd8a76e0cb185d13c1faabf223b60c89fef988c8b89496571056d6c2922109537bb291cd87f2ec09a23ac37d59bde2c7a4885d07b7b641cadff2921@morph-el-0:30303,enode://bd755ce0bc8c06b4444b9013e8d1215a02e2b53f39f746f060c292ba2f6877d7b702374f006a49a7b1506bf1bc027b43824859d081283e6bac97c8600cdf3fee@morph-el-1:30303 + volumes: + - "${PWD}/ha-nodekey2:/p2p-secret.key" diff --git a/ops/docker/docker-compose-reth.yml b/ops/docker/docker-compose-reth.yml index 749e961f1..fbd83cb20 100644 --- a/ops/docker/docker-compose-reth.yml +++ b/ops/docker/docker-compose-reth.yml @@ -9,8 +9,10 @@ # keys below are derived from those same files. # # Peering mirrors the geth static-nodes layout: morph-el-0 and morph-el-1 know -# only each other, while the HA nodes dial both of them and each other. That -# keeps ha-el-* out of the non-cluster setup, where those names do not resolve. +# only each other. The ha-el-* overrides live in docker-compose-cluster-reth.yml +# instead of here, because compose creates every service a later -f file names, +# so overrides parked in this file would also start in the non-cluster devnet, +# where the cluster's mounts are absent and those hostnames do not resolve. # # The command list is spelled out per service because --trusted-peers takes one # comma separated value and YAML cannot concatenate an anchor with extra items. @@ -70,78 +72,3 @@ services: - --trusted-peers=enode://58e698ea2dd8a76e0cb185d13c1faabf223b60c89fef988c8b89496571056d6c2922109537bb291cd87f2ec09a23ac37d59bde2c7a4885d07b7b641cadff2921@morph-el-0:30303 volumes: - "${PWD}/nodekey1:/p2p-secret.key" - - # The ha-el-* overrides below only take effect when - # docker-compose-cluster.yml is passed as well; otherwise these services do - # not exist and compose ignores them. - ha-el-0: - <<: *reth-service - command: - - node - - --chain=/genesis.json - - --datadir=/db - - --http - - --http.addr=0.0.0.0 - - --http.port=8545 - - --http.api=web3,debug,eth,txpool,net,trace,admin,reth - - --ws - - --ws.addr=0.0.0.0 - - --ws.port=8546 - - --ws.api=web3,debug,eth,txpool,net,trace,admin,reth - - --authrpc.addr=0.0.0.0 - - --authrpc.port=8551 - - --authrpc.jwtsecret=/jwt-secret.txt - - --nat=none - - --disable-discovery - - --p2p-secret-key=/p2p-secret.key - - --trusted-peers=enode://73edbabab8727eba67dd5c2964c610decf28ec73e72d5bc395cc5be3c61ebd76cf72ada700b10926c3d2f9e86d64fafb92c6d02deb48c16439040b7c37f86c2d@ha-el-1:30303,enode://4a1a481e53b57ea351a08d945443cdff9cb7911e9121e8240efa99e4214582c0e383a30706349ce1be88b81d0f6344c401823a6624569e20d26880774279c41c@ha-el-2:30303,enode://58e698ea2dd8a76e0cb185d13c1faabf223b60c89fef988c8b89496571056d6c2922109537bb291cd87f2ec09a23ac37d59bde2c7a4885d07b7b641cadff2921@morph-el-0:30303,enode://bd755ce0bc8c06b4444b9013e8d1215a02e2b53f39f746f060c292ba2f6877d7b702374f006a49a7b1506bf1bc027b43824859d081283e6bac97c8600cdf3fee@morph-el-1:30303 - volumes: - - "${PWD}/ha-nodekey0:/p2p-secret.key" - - ha-el-1: - <<: *reth-service - command: - - node - - --chain=/genesis.json - - --datadir=/db - - --http - - --http.addr=0.0.0.0 - - --http.port=8545 - - --http.api=web3,debug,eth,txpool,net,trace,admin,reth - - --ws - - --ws.addr=0.0.0.0 - - --ws.port=8546 - - --ws.api=web3,debug,eth,txpool,net,trace,admin,reth - - --authrpc.addr=0.0.0.0 - - --authrpc.port=8551 - - --authrpc.jwtsecret=/jwt-secret.txt - - --nat=none - - --disable-discovery - - --p2p-secret-key=/p2p-secret.key - - --trusted-peers=enode://343bbe0507fd72946e8d57d5b42630dc905aed7e512945eb2dfd99a0d27bb1c7e84ba621c3408242d713a972a581374ea83a4d134651c373c4f2e8425ba99857@ha-el-0:30303,enode://4a1a481e53b57ea351a08d945443cdff9cb7911e9121e8240efa99e4214582c0e383a30706349ce1be88b81d0f6344c401823a6624569e20d26880774279c41c@ha-el-2:30303,enode://58e698ea2dd8a76e0cb185d13c1faabf223b60c89fef988c8b89496571056d6c2922109537bb291cd87f2ec09a23ac37d59bde2c7a4885d07b7b641cadff2921@morph-el-0:30303,enode://bd755ce0bc8c06b4444b9013e8d1215a02e2b53f39f746f060c292ba2f6877d7b702374f006a49a7b1506bf1bc027b43824859d081283e6bac97c8600cdf3fee@morph-el-1:30303 - volumes: - - "${PWD}/ha-nodekey1:/p2p-secret.key" - - ha-el-2: - <<: *reth-service - command: - - node - - --chain=/genesis.json - - --datadir=/db - - --http - - --http.addr=0.0.0.0 - - --http.port=8545 - - --http.api=web3,debug,eth,txpool,net,trace,admin,reth - - --ws - - --ws.addr=0.0.0.0 - - --ws.port=8546 - - --ws.api=web3,debug,eth,txpool,net,trace,admin,reth - - --authrpc.addr=0.0.0.0 - - --authrpc.port=8551 - - --authrpc.jwtsecret=/jwt-secret.txt - - --nat=none - - --disable-discovery - - --p2p-secret-key=/p2p-secret.key - - --trusted-peers=enode://343bbe0507fd72946e8d57d5b42630dc905aed7e512945eb2dfd99a0d27bb1c7e84ba621c3408242d713a972a581374ea83a4d134651c373c4f2e8425ba99857@ha-el-0:30303,enode://73edbabab8727eba67dd5c2964c610decf28ec73e72d5bc395cc5be3c61ebd76cf72ada700b10926c3d2f9e86d64fafb92c6d02deb48c16439040b7c37f86c2d@ha-el-1:30303,enode://58e698ea2dd8a76e0cb185d13c1faabf223b60c89fef988c8b89496571056d6c2922109537bb291cd87f2ec09a23ac37d59bde2c7a4885d07b7b641cadff2921@morph-el-0:30303,enode://bd755ce0bc8c06b4444b9013e8d1215a02e2b53f39f746f060c292ba2f6877d7b702374f006a49a7b1506bf1bc027b43824859d081283e6bac97c8600cdf3fee@morph-el-1:30303 - volumes: - - "${PWD}/ha-nodekey2:/p2p-secret.key" From 180fd4f5b334cb6c90d90387a4c17910936b653b Mon Sep 17 00:00:00 2001 From: panos Date: Wed, 2 Sep 2026 18:17:41 +0800 Subject: [PATCH 2/2] build: exclude .claude from the docker build context Claude Code keeps its git worktrees under .claude/worktrees. On a machine that has used one, .claude is 1.0 GB -- comparable to .git (1.1 GB) and prover (1.2 GB), both of which .dockerignore already excludes. Nothing under it is ever needed by a build, so every image build was shipping it as build context: ~3.0 GB transferred where ~2.0 GB is required. --- .dockerignore | 1 + ops/devnet-morph/tests/test_devnet_config.py | 4 ++++ 2 files changed, 5 insertions(+) diff --git a/.dockerignore b/.dockerignore index 0ec45acbf..e4d93ad6c 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,5 +1,6 @@ prover/ .git/ +.claude/ contracts/node_modules/ gas-oracle/app/target/ node/build/ diff --git a/ops/devnet-morph/tests/test_devnet_config.py b/ops/devnet-morph/tests/test_devnet_config.py index 0c57b104a..64f9d6cee 100644 --- a/ops/devnet-morph/tests/test_devnet_config.py +++ b/ops/devnet-morph/tests/test_devnet_config.py @@ -17,6 +17,10 @@ def test_root_dockerignore_excludes_generated_build_outputs(self): "gas-oracle/app/target/", "node/build/", "ops/docker/.devnet/", + # Claude Code keeps its worktrees here; on a machine that has used + # one, this is the single largest directory in the repo and every + # image build would ship it as build context. + ".claude/", ): self.assertIn(generated_path, dockerignore)