Skip to content

Commit bae4e7d

Browse files
d4mationclaude
andcommitted
ENG-221: Add parallel build step support via sub-arrays in .puprc
Build and build_dev arrays in .puprc now support sub-arrays to run commands concurrently. Sequential steps still run one at a time, while commands within a sub-array execute in parallel via Promise.all. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent ea08446 commit bae4e7d

6 files changed

Lines changed: 163 additions & 28 deletions

File tree

docs/commands.md

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,29 @@ however, prepend your commands with `@` and that will tell `pup` to ignore failu
5353
In the above example, `npm ci` and `npm run build` will need to complete successfully for the build to succeed, but the
5454
`composer run some-script` is prepended by `@` so if it fails, the build will continue forward.
5555

56+
### Parallel build steps
57+
You can run build steps in parallel by wrapping them in a sub-array. Commands within a sub-array run concurrently, and
58+
all commands in the group must complete before the next step begins. Here's an example:
59+
60+
```json
61+
{
62+
"build": [
63+
"npm ci",
64+
["npm run build:css", "npm run build:js"],
65+
"npm run postbuild"
66+
]
67+
}
68+
```
69+
70+
In the above example:
71+
72+
1. `npm ci` runs first and must complete before anything else.
73+
2. `npm run build:css` and `npm run build:js` run at the same time. Both must finish before continuing.
74+
3. `npm run postbuild` runs last, after the parallel group has completed.
75+
76+
The `@` soft-fail prefix works within parallel groups as well. If a non-soft-fail command in a parallel group fails, `pup`
77+
will wait for all commands in the group to finish before exiting with the failure code.
78+
5679
## `pup check`
5780
Runs all registered check commands.
5881

docs/configuration.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,8 @@ root of the project. This file is a JSON file that contains the configuration op
77

88
| Property | Type | Description |
99
|-------------|-----------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
10-
| `build` | `array` | An array of CLI commands to execute for the build process of your project. |
11-
| `build_dev` | `array` | An array of CLI commands to execute for the `--dev` build process of your project. If empty, it defaults to the value of `build` |
10+
| `build` | `array` | An array of CLI commands to execute for the build process of your project. Supports sub-arrays for [parallel execution](/docs/commands.md#parallel-build-steps). |
11+
| `build_dev` | `array` | An array of CLI commands to execute for the `--dev` build process of your project. If empty, it defaults to the value of `build`. Supports sub-arrays for [parallel execution](/docs/commands.md#parallel-build-steps). |
1212
| `checks` | `object` | An object of check configurations indexed by the check's slug. See the [docs for checks](/docs/checks.md) for more info. |
1313
| `env` | `array` | An array of environment variable names that, if set, should be passed to the build and workflow commands. |
1414
| `paths` | `object` | An object containing paths used by `pup`. [See below](#paths). |

src/commands/build.ts

Lines changed: 62 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,40 @@
11
import type { Command } from 'commander';
22
import { getConfig } from '../config.js';
33
import { runCommand } from '../utils/process.js';
4+
import type { BuildStep, RunCommandResult } from '../types.js';
45
import * as output from '../utils/output.js';
56

7+
/**
8+
* Runs a single build command, handling the `@` soft-fail prefix.
9+
*
10+
* @since TBD
11+
*
12+
* @param {string} step - The command string to execute.
13+
* @param {string} cwd - The working directory for the command.
14+
* @param {string[]} envVarNames - Environment variable names to forward.
15+
*
16+
* @returns {Promise<{ cmd: string; bailOnFailure: boolean; result: RunCommandResult }>} The command, bail flag, and result.
17+
*/
18+
async function runBuildStep(
19+
step: string,
20+
cwd: string,
21+
envVarNames: string[]
22+
): Promise<{ cmd: string; bailOnFailure: boolean; result: RunCommandResult }> {
23+
let cmd = step;
24+
let bailOnFailure = true;
25+
26+
if (cmd.startsWith('@')) {
27+
bailOnFailure = false;
28+
cmd = cmd.slice(1);
29+
}
30+
31+
output.section(`> ${cmd}`);
32+
33+
const result = await runCommand(cmd, { cwd, envVarNames });
34+
35+
return { cmd, bailOnFailure, result };
36+
}
37+
638
/**
739
* Registers the `build` command with the CLI program.
840
*
@@ -20,32 +52,43 @@ export function registerBuildCommand(program: Command): void {
2052
.option('--root <dir>', 'Set the root directory for running commands.')
2153
.action(async (options: { dev?: boolean; root?: string }) => {
2254
const config = getConfig(options.root);
23-
const buildSteps = config.getBuildCommands(options.dev);
55+
const buildSteps: BuildStep[] = config.getBuildCommands(options.dev);
2456
const cwd = options.root ?? config.getWorkingDir();
57+
const envVarNames = config.getEnvVarNames();
2558

2659
output.log('Running build steps...');
2760

2861
for (const step of buildSteps) {
29-
let cmd = step;
30-
let bailOnFailure = true;
62+
if (Array.isArray(step)) {
63+
// Parallel group: run all commands concurrently
64+
const results = await Promise.all(
65+
step.map((cmd) => runBuildStep(cmd, cwd, envVarNames))
66+
);
3167

32-
if (cmd.startsWith('@')) {
33-
bailOnFailure = false;
34-
cmd = cmd.slice(1);
35-
}
36-
37-
output.section(`> ${cmd}`);
38-
39-
const result = await runCommand(cmd, {
40-
cwd,
41-
envVarNames: config.getEnvVarNames(),
42-
});
68+
// Check for failures after all parallel commands complete
69+
for (const { cmd, bailOnFailure, result } of results) {
70+
if (result.exitCode !== 0) {
71+
output.error(`[FAIL] Build step failed: ${cmd}`);
72+
if (bailOnFailure) {
73+
output.error('Exiting...');
74+
process.exit(result.exitCode);
75+
}
76+
}
77+
}
78+
} else {
79+
// Sequential: run single command
80+
const { cmd, bailOnFailure, result } = await runBuildStep(
81+
step,
82+
cwd,
83+
envVarNames
84+
);
4385

44-
if (result.exitCode !== 0) {
45-
output.error(`[FAIL] Build step failed: ${cmd}`);
46-
if (bailOnFailure) {
47-
output.error('Exiting...');
48-
process.exit(result.exitCode);
86+
if (result.exitCode !== 0) {
87+
output.error(`[FAIL] Build step failed: ${cmd}`);
88+
if (bailOnFailure) {
89+
output.error('Exiting...');
90+
process.exit(result.exitCode);
91+
}
4992
}
5093
}
5194
}

src/config.ts

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { normalizeDir, trailingSlashIt } from './utils/directory.js';
55
import { WorkflowCollection, createWorkflow } from './models/workflow.js';
66
import type {
77
PupConfig,
8+
BuildStep,
89
CheckConfig,
910
CheckConfigInput,
1011
VersionFile,
@@ -216,19 +217,19 @@ export class Config {
216217

217218
const rawWorkflows = this.config.workflows as unknown;
218219

219-
// Auto-create build workflow
220+
// Auto-create build workflow (flatten parallel groups for workflow compat)
220221
if (
221222
this.config.build?.length > 0 &&
222223
!(rawWorkflows as Record<string, unknown>)?.['build']
223224
) {
224-
collection.add(createWorkflow('build', this.config.build));
225+
collection.add(createWorkflow('build', this.config.build.flat()));
225226
}
226227

227228
if (
228229
this.config.build_dev?.length > 0 &&
229230
!(rawWorkflows as Record<string, unknown>)?.['build_dev']
230231
) {
231-
collection.add(createWorkflow('build_dev', this.config.build_dev));
232+
collection.add(createWorkflow('build_dev', this.config.build_dev.flat()));
232233
}
233234

234235
if (rawWorkflows && typeof rawWorkflows === 'object') {
@@ -337,9 +338,9 @@ export class Config {
337338
*
338339
* @param {boolean} isDev - Whether to return dev build commands.
339340
*
340-
* @returns {string[]} The list of build command strings.
341+
* @returns {BuildStep[]} The list of build steps (strings run sequentially, sub-arrays run in parallel).
341342
*/
342-
getBuildCommands(isDev = false): string[] {
343+
getBuildCommands(isDev = false): BuildStep[] {
343344
if (isDev && this.config.build_dev?.length > 0) {
344345
return this.config.build_dev;
345346
}

src/types.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
1+
export type BuildStep = string | string[];
2+
13
export interface PupConfig {
2-
build: string[];
3-
build_dev: string[];
4+
build: BuildStep[];
5+
build_dev: BuildStep[];
46
workflows: Record<string, string[]>;
57
checks: Record<string, CheckConfigInput>;
68
clean: string[];

tests/commands/build.test.ts

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,4 +62,70 @@ describe('build command', () => {
6262
expect(result.exitCode).toBe(0);
6363
expect(result.output).toContain('dev build');
6464
});
65+
66+
it('should run parallel build steps', async () => {
67+
const puprc = getPuprc();
68+
puprc.build = [['echo "parallel-a"', 'echo "parallel-b"']];
69+
writePuprc(puprc, projectDir);
70+
71+
const result = await runPup('build', { cwd: projectDir });
72+
expect(result.exitCode).toBe(0);
73+
expect(result.output).toContain('parallel-a');
74+
expect(result.output).toContain('parallel-b');
75+
});
76+
77+
it('should run mixed sequential and parallel steps in order', async () => {
78+
const puprc = getPuprc();
79+
puprc.build = [
80+
'echo "step-1-sequential"',
81+
['echo "step-2-parallel-a"', 'echo "step-2-parallel-b"'],
82+
'echo "step-3-sequential"',
83+
];
84+
writePuprc(puprc, projectDir);
85+
86+
const result = await runPup('build', { cwd: projectDir });
87+
expect(result.exitCode).toBe(0);
88+
expect(result.output).toContain('step-1-sequential');
89+
expect(result.output).toContain('step-2-parallel-a');
90+
expect(result.output).toContain('step-2-parallel-b');
91+
expect(result.output).toContain('step-3-sequential');
92+
93+
// Verify sequential ordering: step-1 before parallel group, parallel group before step-3
94+
const idx1 = result.output.indexOf('step-1-sequential');
95+
const idx2a = result.output.indexOf('step-2-parallel-a');
96+
const idx2b = result.output.indexOf('step-2-parallel-b');
97+
const idx3 = result.output.indexOf('step-3-sequential');
98+
expect(idx1).toBeLessThan(idx2a);
99+
expect(idx1).toBeLessThan(idx2b);
100+
expect(idx2a).toBeLessThan(idx3);
101+
expect(idx2b).toBeLessThan(idx3);
102+
});
103+
104+
it('should handle soft-fail in parallel group', async () => {
105+
const puprc = getPuprc();
106+
puprc.build = [
107+
['@exit 1', 'echo "parallel-ok"'],
108+
'echo "after-parallel"',
109+
];
110+
writePuprc(puprc, projectDir);
111+
112+
const result = await runPup('build', { cwd: projectDir });
113+
expect(result.exitCode).toBe(0);
114+
expect(result.output).toContain('parallel-ok');
115+
expect(result.output).toContain('after-parallel');
116+
});
117+
118+
it('should fail on non-soft-fail failure in parallel group', async () => {
119+
const puprc = getPuprc();
120+
puprc.build = [
121+
['exit 1', 'echo "parallel-ok"'],
122+
'echo "should-not-run"',
123+
];
124+
writePuprc(puprc, projectDir);
125+
126+
const result = await runPup('build', { cwd: projectDir });
127+
expect(result.exitCode).not.toBe(0);
128+
expect(result.output).toContain('parallel-ok');
129+
expect(result.output).not.toContain('should-not-run');
130+
});
65131
});

0 commit comments

Comments
 (0)