A CI pipeline's wall-clock time is not the sum of its jobs — jobs with no dependency between them run in parallel on separate runners. It is the length of the longest dependency chain. Compute that, and identify the jobs on it, because shaving a minute off any other job changes nothing.
Model the workflow as a DAG: each job has a duration and a needs list. Assume unlimited runners, so a job starts the instant all of its dependencies have finished.
Forward pass — earliest finish:
EF(j) = duration(j) + max(EF(d) for d in needs(j)) # 0 if no dependencies
total = max(EF(j) over all j)
Backward pass — latest finish without delaying the pipeline:
LF(j) = total if nothing depends on j
LF(j) = min(LF(c) - duration(c) for c in dependents(j)) otherwise
A job's slack is LF(j) - EF(j). Jobs with zero slack are critical: delay one by a minute and the whole pipeline slips by a minute. Everything else has room to spare. Note there can be several parallel critical chains at once — every zero-slack job belongs in the answer, not just one path.
Implement:
def critical_path(jobs):
Return a dict with keys "total_minutes" (int) and "critical_jobs" (the names with zero slack, sorted alphabetically), in that order.
jobs = [{"name": "checkout", "duration": 1, "needs": []},
{"name": "lint", "duration": 2, "needs": ["checkout"]},
{"name": "unit-tests", "duration": 8, "needs": ["checkout"]},
{"name": "build-image", "duration": 6, "needs": ["lint", "unit-tests"]},
{"name": "deploy", "duration": 3, "needs": ["build-image"]}]
print(critical_path(jobs))
Output:
{'total_minutes': 18, 'critical_jobs': ['build-image', 'checkout', 'deploy', 'unit-tests']}
lint runs in parallel with the 8-minute test job and finishes long before build-image needs it, so it has slack and is not on the critical path — making lint faster would not save a second.
jobs = [{"name": "checkout", "duration": 1, "needs": []}, {"name": "lint", "duration": 2, "needs": ["checkout"]}, {"name": "unit-tests", "duration": 8, "needs": ["checkout"]}, {"name": "build-image", "duration": 6, "needs": ["lint", "unit-tests"]}, {"name": "deploy", "duration": 3, "needs": ["build-image"]}]
print(critical_path(jobs)){'total_minutes': 18, 'critical_jobs': ['build-image', 'checkout', 'deploy', 'unit-tests']}Forward pass: checkout finishes at 1, lint at 3, unit-tests at 9, build-image waits for the later of the two (9) and finishes at 15, deploy at 18. Backward pass: deploy, build-image, unit-tests and checkout all have latest finish equal to their earliest finish, so slack 0. lint could finish as late as 9 but finishes at 3, so it carries 6 minutes of slack and is not critical.
needs refers to an existing joblatest_finish - earliest_finish == 0critical_jobs is sorted alphabetically and may contain several parallel chains