Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
# Changelog

## Unreleased

### Added

- Add `onlyJobs` and `skipJobs` to `networks.yaml` entries for filtering jobs per network by name pattern. `onlyJobs` keeps the transitive dependencies of the jobs it matches.

## 2.0.0

### Breaking changes
Expand Down
20 changes: 20 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -239,6 +239,26 @@ Rules:
- Else, if `skip_networks` is set and non-empty, the job is skipped on those chain IDs.
- Otherwise, the job runs on all networks selected for the run (via `networks.yaml` or `--network`).

#### Per-network job filters

A network can restrict or exclude jobs by name in `networks.yaml`. Patterns support `*` and `?` wildcards:

```yaml
- name: "Monad"
chainId: 143
rpcUrl: "https://rpc.monad.xyz"
# Run only these jobs (plus their dependencies) on this network
onlyJobs: ["trails-*"]
# Skip these jobs on this network, applied after onlyJobs
skipJobs: ["trails-rc-1"]
```

Rules:
- If `onlyJobs` is set, jobs it matches run along with their transitive dependencies; every other job is skipped on that network.
- `skipJobs` then skips every job it matches, including dependencies pulled in by `onlyJobs`.
- A pattern that matches no job matches nothing. With no `onlyJobs` matches, every job is skipped on that network.
- Network job filters combine with the per-job filters above: a job runs only if both allow it.

#### Minimum EVM version per job

Jobs can declare a minimum EVM hardfork they require. When a network’s `evmVersion` is older than the job’s `min_evm_version`, the job is skipped on that network.
Expand Down
8 changes: 8 additions & 0 deletions src/commands/dry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { projectOption, noStdOption, verbosityOption } from './common'
import { validateContractReferences, extractUsedContractReferences } from '../lib/validation/contract-references'
import { setVerbosity } from '../index'
import { resolveSelectedChainIds } from '../lib/network-selection'
import { getNetworkExcludedJobs } from '../lib/network-job-filters'
import { Template } from '../lib/types'

interface DryRunOptions {
Expand Down Expand Up @@ -195,6 +196,13 @@ export function makeDryRunCommand(): Command {
console.log(chalk.blue('\nExecution Plan:'))
console.log(chalk.gray(` - Target Networks: ${targetNetworks.map(n => `${n.name} (ChainID: ${n.chainId})`).join(', ')}`))
console.log(chalk.gray(` - Job Execution Order: ${jobExecutionPlan.join(' -> ')}`))
for (const network of targetNetworks) {
const excluded = getNetworkExcludedJobs(network, fullOrder, graph)
const skipped = jobExecutionPlan.filter(jobName => excluded.has(jobName))
if (skipped.length > 0) {
console.log(chalk.gray(` - Skipped on ${network.name} (ChainID: ${network.chainId}) by network job filters: ${skipped.join(', ')}`))
}
}

console.log(chalk.green.bold('\n✅ Dry run successful. All job and template definitions appear to be valid.'))

Expand Down
2 changes: 2 additions & 0 deletions src/commands/run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,8 @@ export function makeRunCommand(): Command {
gasLimit: detectedNetwork.gasLimit || knownNetwork?.gasLimit,
testnet: detectedNetwork.testnet !== undefined ? detectedNetwork.testnet : knownNetwork?.testnet,
evmVersion: detectedNetwork.evmVersion || knownNetwork?.evmVersion,
onlyJobs: knownNetwork?.onlyJobs,
skipJobs: knownNetwork?.skipJobs,
params: detectedNetwork.params || knownNetwork?.params,
}

Expand Down
116 changes: 116 additions & 0 deletions src/lib/__tests__/deployer.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -259,6 +259,122 @@ describe('Deployer', () => {
expect(usedNetwork.chainId).toBe(1)
})

it('should skip jobs excluded by network job filters', async () => {
const options: DeployerOptions = {
...deployerOptions,
networks: [mockNetwork1, { ...mockNetwork2, skipJobs: ['job2'] }]
}

const deployer = new Deployer(options)
await deployer.run()

const job2Networks = mockEngine.executeJob.mock.calls
.map((call, index) => ({ job: call[0].name, chainId: MockExecutionContext.mock.calls[index][0].chainId }))
.filter(c => c.job === 'job2')
.map(c => c.chainId)
expect(job2Networks).toEqual([1])
})

it('should run an onlyJobs job without its skipJobs dependency', async () => {
// job2 depends on job1
mockGraph.getDependencies.mockImplementation((jobName: string) =>
new Set(jobName === 'job2' ? ['job1'] : [])
)
const options: DeployerOptions = {
...deployerOptions,
networks: [{ ...mockNetwork2, onlyJobs: ['job2'], skipJobs: ['job1'] }]
}

const deployer = new Deployer(options)
await deployer.run()

const executedJobs = mockEngine.executeJob.mock.calls.map(call => call[0].name)
expect(executedJobs).toEqual(['job2'])
})

describe('network and job filter combinations', () => {
// Each network filter crossed with each job filter, for a single job on polygon (137).
// The job runs only when both the network and the job allow it.
const networkFilters: { name: string; filters: Pick<Network, 'onlyJobs' | 'skipJobs'>; allows: boolean }[] = [
{ name: 'no network filters', filters: {}, allows: true },
{ name: 'empty onlyJobs and skipJobs', filters: { onlyJobs: [], skipJobs: [] }, allows: true },
{ name: 'onlyJobs matching the job', filters: { onlyJobs: ['target*'] }, allows: true },
{ name: 'onlyJobs not matching the job', filters: { onlyJobs: ['other-*'] }, allows: false },
{ name: 'skipJobs matching the job', filters: { skipJobs: ['target'] }, allows: false },
{ name: 'skipJobs not matching the job', filters: { skipJobs: ['other-*'] }, allows: true },
{ name: 'onlyJobs and skipJobs both matching the job', filters: { onlyJobs: ['target*'], skipJobs: ['target'] }, allows: false },
]
const jobFilters: { name: string; filters: Partial<Job>; allows: boolean }[] = [
{ name: 'no job filters', filters: {}, allows: true },
{ name: 'empty only_networks and skip_networks', filters: { only_networks: [], skip_networks: [] }, allows: true },
{ name: 'only_networks including the network', filters: { only_networks: [137] }, allows: true },
{ name: 'only_networks excluding the network', filters: { only_networks: [1] }, allows: false },
{ name: 'skip_networks including the network', filters: { skip_networks: [137] }, allows: false },
{ name: 'skip_networks excluding the network', filters: { skip_networks: [1] }, allows: true },
{ name: 'only_networks and skip_networks both including the network', filters: { only_networks: [137], skip_networks: [137] }, allows: true },
{ name: 'min_evm_version above the network evmVersion', filters: { min_evm_version: 'prague' }, allows: false },
{ name: 'min_evm_version at the network evmVersion', filters: { min_evm_version: 'cancun' }, allows: true },
]
const matrix = networkFilters.flatMap(n => jobFilters.map(j => ({
networkCase: n.name,
jobCase: j.name,
networkFilters: n.filters,
jobFilters: j.filters,
runs: n.allows && j.allows,
})))

it.each(matrix)('$networkCase + $jobCase: runs=$runs', async ({ networkFilters, jobFilters, runs }) => {
const job: Job = { ...mockJob1, name: 'target', ...jobFilters }
mockLoader.jobs = new Map([['target', job]])
mockGraph.getExecutionOrder.mockReturnValue(['target'])

const deployer = new Deployer({
...deployerOptions,
networks: [{ ...mockNetwork2, evmVersion: 'cancun', ...networkFilters }]
})
await deployer.run()

expect(mockEngine.executeJob).toHaveBeenCalledTimes(runs ? 1 : 0)
})

describe('dependencies kept by onlyJobs', () => {
const runOnPolygon = async (depFilters: Partial<Job>, targetFilters: Partial<Job>): Promise<string[]> => {
mockLoader.jobs = new Map([
['dep', { ...mockJob1, name: 'dep', ...depFilters }],
['target', { ...mockJob1, name: 'target', depends_on: ['dep'], ...targetFilters }]
])
mockGraph.getExecutionOrder.mockReturnValue(['dep', 'target'])
mockGraph.getDependencies.mockImplementation((jobName: string) =>
new Set(jobName === 'target' ? ['dep'] : [])
)

const deployer = new Deployer({
...deployerOptions,
networks: [{ ...mockNetwork2, onlyJobs: ['target'] }]
})
await deployer.run()

return mockEngine.executeJob.mock.calls.map(call => call[0].name)
}

it('runs the dependency when it has no job filters', async () => {
expect(await runOnPolygon({}, {})).toEqual(['dep', 'target'])
})

it('skips the dependency when its skip_networks includes the network', async () => {
expect(await runOnPolygon({ skip_networks: [137] }, {})).toEqual(['target'])
})

it('skips the dependency when its only_networks excludes the network', async () => {
expect(await runOnPolygon({ only_networks: [1] }, {})).toEqual(['target'])
})

it('runs the dependency when the matched job is skipped by its own skip_networks', async () => {
expect(await runOnPolygon({}, { skip_networks: [137] })).toEqual(['dep'])
})
})
})

it('should create correct output files in flat mode', async () => {
const deployer = new Deployer({ ...deployerOptions, flatOutput: true })
await deployer.run()
Expand Down
67 changes: 67 additions & 0 deletions src/lib/__tests__/network-job-filters.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import { getNetworkExcludedJobs, jobPatternToRegex } from '../network-job-filters'
import { DependencyGraph } from '../core/graph'
import { Network } from '../types'

const jobNames = ['erc2470', 'multicall3', 'sequence-v3', 'trails-rc-1', 'trails-utils', 'builder-factories']

const dependencies: Record<string, string[]> = {
'trails-rc-1': ['erc2470', 'multicall3'],
'trails-utils': ['erc2470'],
'builder-factories': ['erc2470'],
}

const graph = {
getDependencies: (jobName: string) => new Set(dependencies[jobName] ?? []),
} as unknown as DependencyGraph

const network = (filters: Pick<Network, 'onlyJobs' | 'skipJobs'>): Network => ({
name: 'monad',
chainId: 143,
rpcUrl: 'http://127.0.0.1:8545',
...filters,
})

describe('jobPatternToRegex', () => {
it('matches wildcards and escapes regex metacharacters', () => {
expect(jobPatternToRegex('trails-*').test('trails-rc-1')).toBe(true)
expect(jobPatternToRegex('trails-rc-?').test('trails-rc-1')).toBe(true)
expect(jobPatternToRegex('sequence_v3/rc.1').test('sequence_v3/rcx1')).toBe(false)
expect(jobPatternToRegex('trails').test('trails-rc-1')).toBe(false)
})
})

describe('getNetworkExcludedJobs', () => {
it('excludes nothing without filters', () => {
expect(getNetworkExcludedJobs(network({}), jobNames, graph)).toEqual(new Set())
})

it('keeps onlyJobs matches and their dependencies', () => {
const excluded = getNetworkExcludedJobs(network({ onlyJobs: ['trails-*'] }), jobNames, graph)
expect(excluded).toEqual(new Set(['sequence-v3', 'builder-factories']))
})

it('excludes skipJobs matches', () => {
const excluded = getNetworkExcludedJobs(network({ skipJobs: ['builder-*', 'sequence-v3'] }), jobNames, graph)
expect(excluded).toEqual(new Set(['builder-factories', 'sequence-v3']))
})

it('applies skipJobs after onlyJobs, including to dependencies', () => {
const excluded = getNetworkExcludedJobs(network({ onlyJobs: ['trails-*'], skipJobs: ['multicall3'] }), jobNames, graph)
expect(excluded).toEqual(new Set(['sequence-v3', 'builder-factories', 'multicall3']))
})

it('runs only an onlyJobs job when skipJobs removes its dependency', () => {
const jobs = ['job-a', 'job-b']
const abGraph = {
getDependencies: (jobName: string) => new Set(jobName === 'job-a' ? ['job-b'] : []),
} as unknown as DependencyGraph

const excluded = getNetworkExcludedJobs(network({ onlyJobs: ['job-a'], skipJobs: ['job-b'] }), jobs, abGraph)
expect(jobs.filter(name => !excluded.has(name))).toEqual(['job-a'])
})

it('ignores patterns that match no job', () => {
expect(getNetworkExcludedJobs(network({ onlyJobs: ['missing-*'] }), jobNames, graph)).toEqual(new Set(jobNames))
expect(getNetworkExcludedJobs(network({ skipJobs: ['missing'] }), jobNames, graph)).toEqual(new Set())
})
})
37 changes: 37 additions & 0 deletions src/lib/__tests__/network-loader.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -149,3 +149,40 @@ describe('network-loader params', () => {
await expect(loadNetworks(projectRoot)).rejects.toThrow(/Failed to load or parse networks.yaml/)
})
})

describe('network-loader job filters', () => {
afterAll(async () => {
try {
await fs.rm(tmpDir, { recursive: true, force: true })
} catch {}
})

test('passes through onlyJobs and skipJobs', async () => {
const projectRoot = path.join(tmpDir, 'job-filters-valid')
const yaml = `
- name: "MyNet"
chainId: 143
rpcUrl: "http://127.0.0.1:8545"
onlyJobs: ["trails-*"]
skipJobs: ["trails-rc-1"]
`
await writeNetworksYaml(projectRoot, yaml)

const networks = await loadNetworks(projectRoot)
expect(networks[0].onlyJobs).toEqual(['trails-*'])
expect(networks[0].skipJobs).toEqual(['trails-rc-1'])
})

test('rejects skipJobs when not an array of strings', async () => {
const projectRoot = path.join(tmpDir, 'job-filters-invalid')
const yaml = `
- name: "BadNet"
chainId: 143
rpcUrl: "http://127.0.0.1:8545"
skipJobs: "trails-*"
`
await writeNetworksYaml(projectRoot, yaml)

await expect(loadNetworks(projectRoot)).rejects.toThrow(/Failed to load or parse networks.yaml/)
})
})
18 changes: 8 additions & 10 deletions src/lib/deployer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { ExecutionEngine } from './core/engine'
import { createDefaultVerificationRegistry } from './verification/etherscan'
import { ExecutionContext } from './core/context'
import { Network, Job } from './types'
import { getNetworkExcludedJobs, jobPatternToRegex } from './network-job-filters'
import { DeploymentEventEmitter, deploymentEvents } from './events'
import type { RunSummaryEvent } from './events'

Expand Down Expand Up @@ -181,6 +182,11 @@ export class Deployer {
// Emit signer info once per network (chainId)
const signerInfoPrintedForChain = new Set<number>()

const allJobNames = Array.from(this.loader.jobs.keys())
const networkExcludedJobs = new Map<number, Set<string>>(
targetNetworks.map(n => [n.chainId, getNetworkExcludedJobs(n, allJobNames, graph)])
)

for (const network of targetNetworks) {
this.events.emitEvent({
type: 'network_started',
Expand All @@ -198,7 +204,7 @@ export class Deployer {
this.results.set(job.name, { job, outputs: new Map() })
}

if (this.shouldSkipJobOnNetwork(job, network)) {
if (networkExcludedJobs.get(network.chainId)!.has(jobName) || this.shouldSkipJobOnNetwork(job, network)) {
this.events.emitEvent({
type: 'job_skipped',
level: 'warn',
Expand Down Expand Up @@ -523,14 +529,6 @@ export class Deployer {
const allJobNames = Array.from(this.loader.jobs.keys())

const isPattern = (s: string): boolean => /[*?]/.test(s)
const escapeRegex = (s: string): string => s.replace(/[-\\^$+?.()|[\]{}*?]/g, '\\$&')
const patternToRegex = (pattern: string): RegExp => {
// Escape regex metacharacters, then translate wildcard tokens
const escaped = escapeRegex(pattern)
.replace(/\\\*/g, '.*') // escaped '*' -> '.*'
.replace(/\\\?/g, '.') // escaped '?' -> '.'
return new RegExp(`^${escaped}$`)
}

const expanded: string[] = []
const seen = new Set<string>()
Expand All @@ -548,7 +546,7 @@ export class Deployer {
continue
}

const re = patternToRegex(p)
const re = jobPatternToRegex(p)
const matches = allJobNames.filter(name => re.test(name))
if (matches.length === 0) {
throw new Error(`Job pattern "${p}" did not match any jobs in project.`)
Expand Down
41 changes: 41 additions & 0 deletions src/lib/network-job-filters.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { DependencyGraph } from './core/graph'
import { Network } from './types'

/**
* Converts a job name pattern into an anchored regex. `*` matches any run of characters, `?` one character.
*/
export function jobPatternToRegex(pattern: string): RegExp {
const escaped = pattern
.replace(/[-\\^$+?.()|[\]{}*]/g, '\\$&')
.replace(/\\\*/g, '.*')
.replace(/\\\?/g, '.')
return new RegExp(`^${escaped}$`)
}

function matchJobs(patterns: string[], jobNames: string[]): string[] {
const regexes = patterns.map(jobPatternToRegex)
return jobNames.filter(name => regexes.some(re => re.test(name)))
}

/**
* Returns the jobs a network excludes through its `onlyJobs` and `skipJobs` fields.
* Jobs matched by `onlyJobs` keep their transitive dependencies; `skipJobs` is applied afterwards.
*/
export function getNetworkExcludedJobs(network: Network, jobNames: string[], graph: DependencyGraph): Set<string> {
const excluded = new Set<string>()

if (network.onlyJobs && network.onlyJobs.length > 0) {
const allowed = new Set<string>()
for (const name of matchJobs(network.onlyJobs, jobNames)) {
allowed.add(name)
graph.getDependencies(name).forEach(dep => allowed.add(dep))
}
jobNames.filter(name => !allowed.has(name)).forEach(name => excluded.add(name))
}

if (network.skipJobs && network.skipJobs.length > 0) {
matchJobs(network.skipJobs, jobNames).forEach(name => excluded.add(name))
}

return excluded
}
Loading
Loading