@@ -63,9 +63,29 @@ const ensureWritableDir = async (dir: string) => {
6363 await fs . chmod ( dir , 0o777 ) ;
6464} ;
6565
66+ const LOCAL_BUILD_TAG = "fhevm-local" ;
67+
6668const loadComposeDoc = async ( component : string ) =>
6769 YAML . parse ( await fs . readFile ( path . join ( TEMPLATE_COMPOSE_DIR , `${ component } -docker-compose.yml` ) , "utf8" ) ) as ComposeDoc ;
6870
71+ const overriddenServicesForComponent = ( state : State , component : string ) =>
72+ new Set (
73+ state . overrides . flatMap ( ( o ) =>
74+ GROUP_BUILD_COMPONENTS [ o . group ] . includes ( component ) ? GROUP_BUILD_SERVICES [ o . group ] : [ ] ,
75+ ) ,
76+ ) ;
77+
78+ const retagLocal = ( image : unknown ) =>
79+ typeof image === "string" ? image . replace ( / : ( [ ^ : ] + ) $ / , `:${ LOCAL_BUILD_TAG } ` ) : image ;
80+
81+ const applyBuildPolicy = ( service : Record < string , unknown > , isOverridden : boolean ) => {
82+ if ( isOverridden ) {
83+ service . image = retagLocal ( service . image ) ;
84+ } else {
85+ delete service . build ;
86+ }
87+ } ;
88+
6989const appendVolume = ( service : Record < string , unknown > , value : string ) => {
7090 const volumes = Array . isArray ( service . volumes ) ? [ ...service . volumes ] : [ ] ;
7191 if ( ! volumes . includes ( value ) ) {
@@ -184,12 +204,17 @@ const applyInstanceAdjustments = (
184204const buildCoprocessorOverride = async ( state : State ) => {
185205 const doc = rewriteComposePaths ( await loadComposeDoc ( "coprocessor" ) ) ;
186206 const next = structuredClone ( doc ) ;
207+ const overridden = overriddenServicesForComponent ( state , "coprocessor" ) ;
187208 const services : Record < string , Record < string , unknown > > = { } ;
188209 const baseOverride = state . topology . instances [ "coprocessor-0" ] ;
189210 const baseEnv = await readEnvFile ( envPath ( "coprocessor" ) ) ;
190211 const compat = compatPolicyForState ( state ) ;
212+ // Skip compat args for overridden services — local builds use HEAD code, not the resolved version.
213+ const compatArgs = overridden . size ? { } : compat . coprocessorArgs ;
191214 for ( const [ name , service ] of Object . entries ( doc . services ) ) {
192- services [ name ] = applyInstanceAdjustments ( service , envPath ( "coprocessor" ) , baseEnv , baseOverride , compat . coprocessorArgs ) ;
215+ const adjusted = applyInstanceAdjustments ( service , envPath ( "coprocessor" ) , baseEnv , baseOverride , compatArgs ) ;
216+ applyBuildPolicy ( adjusted , overridden . has ( name ) ) ;
217+ services [ name ] = adjusted ;
193218 }
194219 for ( let index = 1 ; index < state . topology . count ; index += 1 ) {
195220 const prefix = `coprocessor${ index } -` ;
@@ -202,9 +227,10 @@ const buildCoprocessorOverride = async (state: State) => {
202227 envPath ( `coprocessor.${ index } ` ) ,
203228 instanceEnv ,
204229 override ,
205- compat . coprocessorArgs ,
230+ compatArgs ,
206231 ) ;
207232 cloned . container_name = prefix + suffix ;
233+ applyBuildPolicy ( cloned , overridden . has ( name ) ) ;
208234 if ( cloned . depends_on && typeof cloned . depends_on === "object" ) {
209235 cloned . depends_on = Object . fromEntries (
210236 Object . entries ( cloned . depends_on as Record < string , unknown > ) . map ( ( [ dep , value ] ) => [
@@ -225,9 +251,11 @@ const buildComposeOverride = async (component: string, state: State) => {
225251 return buildCoprocessorOverride ( state ) ;
226252 }
227253 const doc = rewriteComposePaths ( structuredClone ( await loadComposeDoc ( component ) ) ) ;
254+ const overridden = overriddenServicesForComponent ( state , component ) ;
228255 const envVars = await readEnvFile ( envPath ( component ) ) ;
229256 for ( const [ name , service ] of Object . entries ( doc . services ) ) {
230257 Object . assign ( service , interpolateComposeValue ( service , envVars ) ) ;
258+ applyBuildPolicy ( service , overridden . has ( name ) ) ;
231259 if ( component === "gateway-sc" ) {
232260 if ( name === "gateway-sc-add-network" ) {
233261 service . command = [ "npx hardhat task:addHostChainsToGatewayConfig --use-internal-proxy-address true" ] ;
@@ -456,8 +484,22 @@ const maybeBuild = async (
456484 if ( ! services . length ) {
457485 continue ;
458486 }
487+ // Deduplicate services by image tag — buildx fails when two services
488+ // produce the same output tag in a single `docker compose build`.
489+ const seen = new Set < string > ( ) ;
490+ const deduped = services . filter ( ( s ) => {
491+ const img = doc . services [ s ] ?. image ;
492+ if ( typeof img !== "string" || seen . has ( img ) ) {
493+ return false ;
494+ }
495+ seen . add ( img ) ;
496+ return true ;
497+ } ) ;
459498 log ( `[build] ${ override . group } (${ component } )` ) ;
460- await deps . liveRunner ( [ ...dockerArgs ( component ) , "build" , ...services ] , { env : await composeEnv ( state ) } ) ;
499+ for ( const ref of await imageRefsForServices ( component , deduped ) ) {
500+ await deps . runner ( [ "docker" , "image" , "rm" , "-f" , ref ] , { allowFailure : true } ) ;
501+ }
502+ await deps . liveRunner ( [ ...dockerArgs ( component ) , "build" , ...deduped ] , { env : await composeEnv ( state ) } ) ;
461503 await rememberBuiltImages ( state , component , override . group , services , deps , saveState ) ;
462504 }
463505 }
0 commit comments