From ee272cfbede5adfb18a60d1a269af16dd650e8e1 Mon Sep 17 00:00:00 2001 From: Michael Standen Date: Wed, 1 Oct 2025 07:47:34 +1300 Subject: [PATCH] Skipped jobs don't prevent dependent jobs from running --- src/lib/__tests__/deployer.spec.ts | 414 +++++++++++++++++++++++++++++ src/lib/core/engine.ts | 2 +- src/lib/deployer.ts | 157 ++++++----- 3 files changed, 509 insertions(+), 64 deletions(-) diff --git a/src/lib/__tests__/deployer.spec.ts b/src/lib/__tests__/deployer.spec.ts index b0bd981..2ace067 100644 --- a/src/lib/__tests__/deployer.spec.ts +++ b/src/lib/__tests__/deployer.spec.ts @@ -1557,6 +1557,108 @@ describe('Deployer', () => { expect(jobBResult.outputs.get(mockNetwork1.chainId).status).toBe('success') }) + it('should allow job B to run when job A is skipped', async () => { + // This tests the scenario where job A is skipped (e.g., due to skip_condition) + // but job B should still be allowed to run since it doesn't depend on job A's outputs + + const jobA: Job = { + name: 'job-a', + version: '1', + description: 'Job A that will be skipped', + skip_condition: [true], // This will cause job A to be skipped + actions: [ + { + name: 'deploy-step', + type: 'create-contract', + arguments: { + bytecode: TEST_BYTECODES.SIMPLE_CONTRACT, + value: '0' + }, + output: true + } + ] + } + + const jobB: Job = { + name: 'job-b', + version: '1', + description: 'Job B that should run even if job A is skipped', + depends_on: ['job-a'], // Declares dependency on job A + actions: [ + { + name: 'independent-action', + type: 'send-transaction', + arguments: { + to: '0x1234567890123456789012345678901234567890', + data: '0x', + value: '0' + } + } + ] + } + + // Setup mocks + const mockJobs = new Map() + mockJobs.set('job-a', jobA) + mockJobs.set('job-b', jobB) + + const mockTemplates = new Map() + + MockProjectLoader.mockImplementation(() => ({ + load: jest.fn().mockResolvedValue(undefined), + jobs: mockJobs, + templates: mockTemplates + } as any)) + + MockDependencyGraph.mockImplementation(() => ({ + getExecutionOrder: jest.fn().mockReturnValue(['job-a', 'job-b']), + getDependencies: jest.fn().mockReturnValue(new Set()) + } as any)) + + // Mock engine to simulate job A being skipped and job B running successfully + MockExecutionEngine.mockImplementation(() => ({ + executeJob: jest.fn().mockImplementation(async (job: Job) => { + if (job.name === 'job-a') { + // Job A should be skipped due to skip_condition + throw new Error('Job "job-a" skipped due to skip condition') + } else if (job.name === 'job-b') { + // Job B should run successfully even though job A was skipped + return Promise.resolve() + } + }), + evaluateSkipConditions: jest.fn().mockImplementation(async (conditions: any, context: any, scope: any) => { + // For job-a with skip_condition: [true], return true (should skip) + // For other jobs, return false (should not skip) + return conditions && conditions.length > 0 && conditions[0] === true + }) + } as any)) + + MockExecutionContext.mockImplementation(() => ({ + dispose: jest.fn().mockResolvedValue(undefined), + getNetwork: jest.fn().mockReturnValue(mockNetwork1), + getOutputs: jest.fn().mockReturnValue(new Map([ + ['independent-action.hash', '0xmocktransactionhash'] + ])) + } as any)) + + const deployer = new Deployer(deployerOptions) + + // Execute deployer - should succeed since job B can run independently + await expect(deployer.run()).resolves.not.toThrow() + + // Verify that job A was skipped + const results = (deployer as any).results + const jobAResult = results.get('job-a') + expect(jobAResult).toBeDefined() + expect(jobAResult.outputs.get(mockNetwork1.chainId).status).toBe('skipped') + expect(jobAResult.outputs.get(mockNetwork1.chainId).data).toContain('skipped due to skip condition') + + // Verify that job B ran successfully + const jobBResult = results.get('job-b') + expect(jobBResult).toBeDefined() + expect(jobBResult.outputs.get(mockNetwork1.chainId).status).toBe('success') + }) + it('should fail job B when job A fails, even with complex output references', async () => { // This tests the scenario where job B references multiple outputs from job A // and job A fails, ensuring job B fails due to dependency failure, not expression resolution @@ -1648,4 +1750,316 @@ describe('Deployer', () => { expect(jobBError.data).toContain('depends on "job-a", but "job-a" failed') }) }) + + describe('run summary functionality', () => { + let mockEventEmitter: jest.Mocked + let deployer: Deployer + + beforeEach(() => { + // Create a mock event emitter to track events + mockEventEmitter = { + emitEvent: jest.fn() + } + + // Create deployer with mock event emitter + deployer = new Deployer({ + ...deployerOptions, + eventEmitter: mockEventEmitter as any + }) + }) + + it('should emit run summary with success counts when all jobs succeed', () => { + // Mock the results property to simulate successful job execution + const mockResults = new Map() + mockResults.set('job1', { + job: mockJob1, + outputs: new Map([ + [1, { status: 'success', data: new Map([['action1.hash', '0xhash1'], ['action1.address', '0x1234567890123456789012345678901234567890']]) }], + [137, { status: 'success', data: new Map([['action1.hash', '0xhash1'], ['action1.address', '0x1234567890123456789012345678901234567890']]) }] + ]) + }) + mockResults.set('job2', { + job: mockJob2, + outputs: new Map([ + [1, { status: 'success', data: new Map([['action2.hash', '0xhash2'], ['action2.address', '0x9876543210987654321098765432109876543210']]) }], + [137, { status: 'success', data: new Map([['action2.hash', '0xhash2'], ['action2.address', '0x9876543210987654321098765432109876543210']]) }] + ]) + }) + mockResults.set('job3', { + job: mockJob3, + outputs: new Map([ + [1, { status: 'success', data: new Map([['action3.hash', '0xhash3']]) }] + ]) + }) + + // Set the results property + ;(deployer as any).results = mockResults + + // Call emitRunSummary directly + ;(deployer as any).emitRunSummary(false) + + // Verify run summary was emitted + expect(mockEventEmitter.emitEvent).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'run_summary', + level: 'info', // Should be 'info' when no failures + data: expect.objectContaining({ + networkCount: 2, // mainnet and polygon + jobCount: 3, // job1, job2, job3 + successCount: 5, // job1&job2 on 2 networks + job3 on 1 network + failedCount: 0, + skippedCount: 0, + keyContracts: expect.arrayContaining([ + { job: 'job1', action: 'action1', address: '0x1234567890123456789012345678901234567890' }, + { job: 'job2', action: 'action2', address: '0x9876543210987654321098765432109876543210' } + ]) + }) + }) + ) + }) + + it('should emit run summary with failure counts when some jobs fail', () => { + // Mock the results property to simulate mixed success/failure + const mockResults = new Map() + mockResults.set('job1', { + job: mockJob1, + outputs: new Map([ + [1, { status: 'error', data: 'Job1 failed' }], + [137, { status: 'error', data: 'Job1 failed' }] + ]) + }) + mockResults.set('job2', { + job: mockJob2, + outputs: new Map([ + [1, { status: 'success', data: new Map([['action2.hash', '0xhash2'], ['action2.address', '0x9876543210987654321098765432109876543210']]) }], + [137, { status: 'success', data: new Map([['action2.hash', '0xhash2'], ['action2.address', '0x9876543210987654321098765432109876543210']]) }] + ]) + }) + mockResults.set('job3', { + job: mockJob3, + outputs: new Map([ + [1, { status: 'success', data: new Map([['action3.hash', '0xhash3']]) }] + ]) + }) + + // Set the results property + ;(deployer as any).results = mockResults + + // Call emitRunSummary with hasFailures = true + ;(deployer as any).emitRunSummary(true) + + // Verify run summary was emitted with failure info + expect(mockEventEmitter.emitEvent).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'run_summary', + level: 'warn', // Should be 'warn' when there are failures + data: expect.objectContaining({ + networkCount: 2, + jobCount: 3, + successCount: 3, // job2&job3 succeed on their networks + failedCount: 2, // job1 fails on both networks + skippedCount: 0, + keyContracts: expect.arrayContaining([ + { job: 'job2', action: 'action2', address: '0x9876543210987654321098765432109876543210' } + ]) + }) + }) + ) + }) + + it('should emit run summary with skipped counts when jobs are skipped', () => { + // Mock the results property to simulate skipped jobs + const mockResults = new Map() + mockResults.set('job1', { + job: mockJob1, + outputs: new Map([ + [1, { status: 'skipped', data: 'Job skipped due to network filter' }], + [137, { status: 'skipped', data: 'Job skipped due to network filter' }] + ]) + }) + + // Set the results property + ;(deployer as any).results = mockResults + + // Call emitRunSummary with hasFailures = false + ;(deployer as any).emitRunSummary(false) + + // Verify run summary was emitted with skipped info + expect(mockEventEmitter.emitEvent).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'run_summary', + level: 'info', + data: expect.objectContaining({ + networkCount: 2, + jobCount: 1, + successCount: 0, + failedCount: 0, + skippedCount: 2, // job1 skipped on both networks + keyContracts: [] + }) + }) + ) + }) + + it('should limit key contracts to 10 entries', () => { + // Create a job with many contract addresses + const manyContractsJob: Job = { + name: 'many-contracts-job', + version: '1.0.0', + actions: Array.from({ length: 15 }, (_, i) => ({ + name: `action${i}`, + template: 'template1', + arguments: {} + })) + } + + // Mock the results property with many contract addresses + const mockResults = new Map() + const manyOutputs = new Map() + for (let i = 0; i < 15; i++) { + manyOutputs.set(`action${i}.address`, `0x${i.toString().padStart(40, '0')}`) + manyOutputs.set(`action${i}.hash`, `0xhash${i}`) + } + + mockResults.set('many-contracts-job', { + job: manyContractsJob, + outputs: new Map([ + [1, { status: 'success', data: manyOutputs }] + ]) + }) + + // Set the results property + ;(deployer as any).results = mockResults + + // Call emitRunSummary + ;(deployer as any).emitRunSummary(false) + + // Verify run summary limits key contracts to 10 + expect(mockEventEmitter.emitEvent).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'run_summary', + data: expect.objectContaining({ + keyContracts: expect.arrayContaining([ + { job: 'many-contracts-job', action: 'action0', address: '0x0000000000000000000000000000000000000000' }, + { job: 'many-contracts-job', action: 'action1', address: '0x0000000000000000000000000000000000000001' }, + // ... up to action9 + { job: 'many-contracts-job', action: 'action9', address: '0x0000000000000000000000000000000000000009' } + ]) + }) + }) + ) + + // Verify only 10 contracts are included + const summaryCall = mockEventEmitter.emitEvent.mock.calls.find((call: any) => + call[0].type === 'run_summary' + ) + expect(summaryCall![0].data.keyContracts).toHaveLength(10) + }) + + it('should not emit run summary when showSummary is false', () => { + const deployerWithoutSummary = new Deployer({ + ...deployerOptions, + eventEmitter: mockEventEmitter as any, + showSummary: false + }) + + // Verify that showSummary is false + expect((deployerWithoutSummary as any).showSummary).toBe(false) + + // Mock the results property + const mockResults = new Map() + mockResults.set('job1', { + job: mockJob1, + outputs: new Map([ + [1, { status: 'success', data: new Map([['action1.hash', '0xhash1']]) }] + ]) + }) + ;(deployerWithoutSummary as any).results = mockResults + + // Call emitRunSummary directly - this will emit regardless of showSummary + // The showSummary check happens in the run() method, not in emitRunSummary itself + ;(deployerWithoutSummary as any).emitRunSummary(false) + + // Verify run summary WAS emitted (because we called it directly) + expect(mockEventEmitter.emitEvent).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'run_summary' + }) + ) + }) + + it('should emit run summary with mixed success/failure/skipped counts', () => { + // Mock the results property to simulate mixed outcomes + const mockResults = new Map() + mockResults.set('success-job', { + job: { name: 'success-job', version: '1.0.0', actions: [{ name: 'success-action', template: 'template1', arguments: {} }] }, + outputs: new Map([ + [1, { status: 'success', data: new Map([['success-action.hash', '0xsuccess'], ['success-action.address', '0x1234567890123456789012345678901234567890']]) }], + [137, { status: 'success', data: new Map([['success-action.hash', '0xsuccess'], ['success-action.address', '0x1234567890123456789012345678901234567890']]) }] + ]) + }) + mockResults.set('fail-job', { + job: { name: 'fail-job', version: '1.0.0', actions: [{ name: 'fail-action', template: 'template1', arguments: {} }] }, + outputs: new Map([ + [1, { status: 'error', data: 'Fail job failed' }], + [137, { status: 'error', data: 'Fail job failed' }] + ]) + }) + mockResults.set('skipped-job', { + job: { name: 'skipped-job', version: '1.0.0', actions: [{ name: 'skipped-action', template: 'template1', arguments: {} }] }, + outputs: new Map([ + [1, { status: 'skipped', data: 'Job skipped' }], + [137, { status: 'skipped', data: 'Job skipped' }] + ]) + }) + + // Set the results property + ;(deployer as any).results = mockResults + + // Call emitRunSummary with hasFailures = true + ;(deployer as any).emitRunSummary(true) + + // Verify run summary with mixed counts + expect(mockEventEmitter.emitEvent).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'run_summary', + level: 'warn', // Should be 'warn' due to failures + data: expect.objectContaining({ + networkCount: 2, + jobCount: 3, + successCount: 2, // success-job on both networks + failedCount: 2, // fail-job on both networks + skippedCount: 2, // skipped-job on both networks + keyContracts: expect.arrayContaining([ + { job: 'success-job', action: 'success-action', address: '0x1234567890123456789012345678901234567890' } + ]) + }) + }) + ) + }) + + it('should handle empty results gracefully', () => { + // Set empty results + ;(deployer as any).results = new Map() + + // Call emitRunSummary + ;(deployer as any).emitRunSummary(false) + + // Verify run summary with empty results + expect(mockEventEmitter.emitEvent).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'run_summary', + level: 'info', + data: expect.objectContaining({ + networkCount: 2, + jobCount: 0, + successCount: 0, + failedCount: 0, + skippedCount: 0, + keyContracts: [] + }) + }) + ) + }) + }) }) diff --git a/src/lib/core/engine.ts b/src/lib/core/engine.ts index 9d64f4a..f5f5f16 100644 --- a/src/lib/core/engine.ts +++ b/src/lib/core/engine.ts @@ -1632,7 +1632,7 @@ export class ExecutionEngine { /** * Evaluates a list of conditions and returns true if any of them are met. */ - private async evaluateSkipConditions( + public async evaluateSkipConditions( conditions: Condition[] | undefined, context: ExecutionContext, scope: ResolutionScope, diff --git a/src/lib/deployer.ts b/src/lib/deployer.ts index 34cae31..40a2f84 100644 --- a/src/lib/deployer.ts +++ b/src/lib/deployer.ts @@ -16,31 +16,31 @@ import type { RunSummaryEvent } from './events' export interface DeployerOptions { /** The root directory of the deployment project. */ projectRoot: string - + /** The private key of the EOA to be used as the signer/relayer. Optional if an implicit sender from RPC is desired. */ privateKey?: string - + /** An array of network configurations to use for deployment. */ networks: Network[] - + /** Optional: An array of job names to execute. If not provided, all jobs are considered. */ runJobs?: string[] - + /** Optional: An array of chain IDs to run on. If not provided, all configured networks are used. */ runOnNetworks?: number[] - + /** Optional: Custom event emitter instance. If not provided, uses the global singleton. */ eventEmitter?: DeploymentEventEmitter - + /** Optional: Project loader options (e.g., whether to load standard templates). */ loaderOptions?: ProjectLoaderOptions - + /** Optional Etherscan API key for contract verification. */ etherscanApiKey?: string - + /** Optional: Stop execution as soon as any job fails. Defaults to false. */ failEarly?: boolean - + /** Optional: Skip post-execution check of skip conditions. Defaults to false (post-check enabled). */ noPostCheckConditions?: boolean @@ -68,11 +68,11 @@ export class Deployer { private readonly loader: ProjectLoader private readonly noPostCheckConditions: boolean private readonly showSummary: boolean - + // Store both successful and failed execution results private readonly results = new Map | string }> + outputs: Map | string }> }>() private graph?: DependencyGraph @@ -97,7 +97,7 @@ export class Deployer { projectRoot: this.options.projectRoot } }) - + try { // 1. Load all project artifacts, templates, and jobs. this.events.emitEvent({ @@ -107,9 +107,9 @@ export class Deployer { projectRoot: this.options.projectRoot } }) - + await this.loader.load() - + this.events.emitEvent({ type: 'project_loaded', level: 'info', @@ -118,7 +118,7 @@ export class Deployer { templateCount: this.loader.templates.size } }) - + // 2. Build the dependency graph and determine execution order. const graph = new DependencyGraph(this.loader.jobs, this.loader.templates) this.graph = graph @@ -142,7 +142,7 @@ export class Deployer { } } const targetNetworks = this.getTargetNetworks() - + this.events.emitEvent({ type: 'execution_plan', level: 'info', @@ -163,12 +163,12 @@ export class Deployer { noPostCheckConditions: this.noPostCheckConditions, ignoreVerifyErrors: this.options.ignoreVerifyErrors ?? false }) - + // Track if any jobs have failed let hasFailures = false // Emit signer info once per network (chainId) const signerInfoPrintedForChain = new Set() - + for (const network of targetNetworks) { this.events.emitEvent({ type: 'network_started', @@ -180,7 +180,12 @@ export class Deployer { }) for (const jobName of jobsToRun) { const job = this.loader.jobs.get(jobName)! - + + // Initialize results storage for this job if not exists + if (!this.results.has(job.name)) { + this.results.set(job.name, { job, outputs: new Map() }) + } + if (this.shouldSkipJobOnNetwork(job, network)) { this.events.emitEvent({ type: 'job_skipped', @@ -191,14 +196,16 @@ export class Deployer { reason: 'configuration' } }) + + // Store skipped result + this.results.get(job.name)!.outputs.set(network.chainId, { + status: 'skipped', + data: 'Job skipped due to network configuration' + }) + continue } - - // Initialize results storage for this job if not exists - if (!this.results.has(job.name)) { - this.results.set(job.name, { job, outputs: new Map() }) - } - + let context: ExecutionContext | undefined try { context = new ExecutionContext( @@ -212,7 +219,7 @@ export class Deployer { if (typeof (context as unknown as { setJobConstants?: (constants: unknown) => void }).setJobConstants === 'function') { (context as unknown as { setJobConstants: (constants: unknown) => void }).setJobConstants(job.constants) } - + // Emit signer info once per network using the first job's context if (!signerInfoPrintedForChain.has(network.chainId)) { try { @@ -253,11 +260,35 @@ export class Deployer { } } + // Check job-level skip conditions before execution + if (job.skip_condition) { + const shouldSkip = await engine.evaluateSkipConditions(job.skip_condition, context, new Map()) + if (shouldSkip) { + // Store skipped result + this.results.get(job.name)!.outputs.set(network.chainId, { + status: 'skipped', + data: `Job "${job.name}" skipped due to skip condition` + }) + + this.events.emitEvent({ + type: 'job_skipped', + level: 'warn', + data: { + jobName: job.name, + networkName: network.name, + reason: 'skip_condition' + } + }) + + continue // Skip to next job + } + } + // Populate context with outputs from previously executed dependent jobs this.populateContextWithDependentJobOutputs(job, context, network) - + await engine.executeJob(job, context) - + // Store successful results this.results.get(job.name)!.outputs.set(network.chainId, { status: 'success', @@ -270,7 +301,7 @@ export class Deployer { status: 'error', data: errorMessage }) - + this.events.emitEvent({ type: 'job_execution_failed', level: 'error', @@ -281,15 +312,15 @@ export class Deployer { error: errorMessage } }) - + // Mark that we have failures hasFailures = true - + // If fail-early is enabled, throw the error immediately if (this.options.failEarly) { throw error } - + // Otherwise, continue to next job/network } finally { // Clean up the context to prevent hanging connections @@ -312,7 +343,7 @@ export class Deployer { } } } - + // 5. Write results to output files. await this.writeOutputFiles() @@ -386,18 +417,13 @@ export class Deployer { const jobCount = this.results.size let successCount = 0 let failedCount = 0 - const skippedCount = 0 - - // Detect skipped by comparing planned jobs across networks vs executed entries - // Here we approximate: an entry exists per job and per network outcome. We count - // successes/errors; skips were emitted as events during execution and are not persisted - // in results. We cannot perfectly reconstruct skipped count without tracking, so we - // expose it as 0 for now; could be improved by tracking per-network skip events. + let skippedCount = 0 for (const [, result] of this.results) { for (const [, netResult] of result.outputs) { if (netResult.status === 'success') successCount++ - else failedCount++ + else if (netResult.status === 'skipped') skippedCount++ + else if (netResult.status === 'error') failedCount++ } } @@ -437,7 +463,7 @@ export class Deployer { */ private emitVerificationWarningsReport(engine: ExecutionEngine): void { const warnings = engine.getVerificationWarnings() - + if (warnings.length > 0) { this.events.emitEvent({ type: 'verification_warnings_report', @@ -530,7 +556,7 @@ export class Deployer { const allowed = new Set([...nonDeprecatedJobs, ...requiredDeprecated]) return fullOrder.filter(name => allowed.has(name)) } - + // Expand patterns to concrete names const expandedRunJobs = expandRunJobs(this.options.runJobs) const explicitlyRequested = new Set(expandedRunJobs) @@ -557,7 +583,7 @@ export class Deployer { return this.options.runDeprecated === true }) const allowedSet = new Set(filtered) - + // Filter the original execution order to only include the required jobs, preserving the correct sequence. return fullOrder.filter(jobName => allowedSet.has(jobName)) } @@ -569,10 +595,10 @@ export class Deployer { if (!this.options.runOnNetworks || this.options.runOnNetworks.length === 0) { return this.options.networks // Run on all configured networks } - + const targetChainIds = new Set(this.options.runOnNetworks) const filteredNetworks = this.options.networks.filter(n => targetChainIds.has(n.chainId)) - + if (filteredNetworks.length !== this.options.runOnNetworks.length) { const foundIds = new Set(filteredNetworks.map(n => n.chainId)) const missingIds = this.options.runOnNetworks.filter(id => !foundIds.has(id)) @@ -584,7 +610,7 @@ export class Deployer { } }) } - + return filteredNetworks } @@ -611,7 +637,7 @@ export class Deployer { } } } - + // Check minimal EVM hardfork requirement if present on job and network declares an EVM version if (jobWithNetworkFilters.min_evm_version) { const jobMin = this.normalizeEvmVersion(jobWithNetworkFilters.min_evm_version) @@ -622,7 +648,7 @@ export class Deployer { } // If network has no evmVersion declared, do not skip (assume compatible) } - + return false // Run by default } @@ -704,6 +730,11 @@ export class Deployer { throw new Error(`Job "${job.name}" depends on "${dependentJobName}", but "${dependentJobName}" has not been executed on network ${network.name} (chainId: ${network.chainId}).`) } + // Skip jobs don't provide outputs, but they don't prevent dependent jobs from running + if (networkResult.status === 'skipped') { + continue + } + if (networkResult.status !== 'success') { const errorMessage = typeof networkResult.data === 'string' ? networkResult.data : 'Unknown error' throw new Error(`Job "${job.name}" depends on "${dependentJobName}", but "${dependentJobName}" failed: ${errorMessage}`) @@ -736,7 +767,7 @@ export class Deployer { const outputRoot = path.join(this.options.projectRoot, 'output') await fs.mkdir(outputRoot, { recursive: true }) - + this.events.emitEvent({ type: 'output_writing_started', level: 'info' @@ -765,7 +796,7 @@ export class Deployer { const outputFilePath = path.join(outputRoot, relativeJobSubpath) const outputFileDir = path.dirname(outputFilePath) await fs.mkdir(outputFileDir, { recursive: true }) - + // Group networks by identical status and outputs const groupedResults = this.groupNetworkResults(resultData.outputs, resultData.job) @@ -775,7 +806,7 @@ export class Deployer { lastRun: new Date().toISOString(), networks: groupedResults } - + await fs.writeFile(outputFilePath, JSON.stringify(fileContent, null, 2)) this.events.emitEvent({ type: 'output_file_written', @@ -875,20 +906,20 @@ export class Deployer { */ private filterOutDependencyOutputs(outputs: Map, job: Job): Record { const filtered = new Map() - + // Get list of dependency job names const dependencyNames = job.depends_on || [] - + for (const [key, value] of outputs) { // Check if this output key starts with any dependency job name prefix const isDependencyOutput = dependencyNames.some(depName => key.startsWith(`${depName}.`)) - + // Only include outputs that are NOT from dependencies if (!isDependencyOutput) { filtered.set(key, value) } } - + return Object.fromEntries(filtered) } @@ -897,8 +928,8 @@ export class Deployer { * - Success states with identical outputs are grouped together with chainIds array * - Error states are kept separate (one entry per network) */ - private groupNetworkResults(outputs: Map | string }>, job: Job): Array<{ - status: 'success' | 'error'; + private groupNetworkResults(outputs: Map | string }>, job: Job): Array<{ + status: 'success' | 'error' | 'skipped'; chainIds?: string[]; chainId?: string; outputs?: Record; @@ -910,20 +941,20 @@ export class Deployer { chainId: string; error: string; }> = [] - + for (const [chainId, result] of outputs.entries()) { if (result.status === 'success') { // Group successful results by identical outputs, filtered by action output flags const outputsObj = result.data instanceof Map ? this.filterOutputsByActionFlags(result.data, job) : {} const key = JSON.stringify(outputsObj) - + if (!successGroups.has(key)) { successGroups.set(key, { chainIds: [], outputs: outputsObj }) } - + successGroups.get(key)!.chainIds.push(chainId.toString()) } else { // Keep error results separate - one entry per network @@ -934,14 +965,14 @@ export class Deployer { }) } } - + // Convert success groups to array format const successEntries = Array.from(successGroups.values()).map(group => ({ status: 'success' as const, chainIds: group.chainIds.sort(), // Sort for consistent output outputs: group.outputs })) - + // Return all entries: successes first, then errors return [...successEntries, ...errorEntries] }