diff --git a/docs/commands.md b/docs/commands.md
index ec94b9b..339302d 100644
--- a/docs/commands.md
+++ b/docs/commands.md
@@ -53,6 +53,45 @@ however, prepend your commands with `@` and that will tell `pup` to ignore failu
In the above example, `npm ci` and `npm run build` will need to complete successfully for the build to succeed, but the
`composer run some-script` is prepended by `@` so if it fails, the build will continue forward.
+### Parallel build steps
+You can run build steps in parallel by wrapping them in a sub-array. Commands within a sub-array run concurrently, and
+all commands in the group must complete before the next step begins. Here's an example:
+
+```json
+{
+ "build": [
+ "npm ci",
+ ["npm run build:css", "npm run build:js"],
+ "npm run postbuild"
+ ]
+}
+```
+
+In the above example:
+
+1. `npm ci` runs first and must complete before anything else.
+2. `npm run build:css` and `npm run build:js` run at the same time. Both must finish before continuing.
+3. `npm run postbuild` runs last, after the parallel group has completed.
+
+The `@` soft-fail prefix works within parallel groups as well. If a non-soft-fail command in a parallel group fails, `pup`
+will wait for all commands in the group to finish before exiting with the failure code.
+
+### A note on parallel output
+
+Commands in a parallel group share the same terminal output. Their output will be interleaved line-by-line as each
+process writes to `stdout`, and section headers for all commands in the group are printed before any command begins
+producing output.
+
+Because of this, parallel execution may **appear** sequential in some cases even though the commands are genuinely
+running simultaneously. Common reasons include:
+
+- **Fast-finishing commands**: If one command completes quickly (e.g. `bun install` with a warm cache), all of its
+ output appears before the slower command has produced much. This can look like the commands ran one after another.
+- **I/O contention**: Multiple commands competing for CPU, disk, or network may cause one to stall while another
+ progresses, further contributing to output that appears sequential.
+
+If you need to verify that commands are truly running in parallel, compare the runtimes between both parallel and sequential configurations.
+
## `pup check`
Runs all registered check commands.
diff --git a/docs/configuration.md b/docs/configuration.md
index 4f57d0e..90a3025 100644
--- a/docs/configuration.md
+++ b/docs/configuration.md
@@ -7,8 +7,8 @@ root of the project. This file is a JSON file that contains the configuration op
| Property | Type | Description |
|-------------|-----------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
-| `build` | `array` | An array of CLI commands to execute for the build process of your project. |
-| `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` |
+| `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). |
+| `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). |
| `checks` | `object` | An object of check configurations indexed by the check's slug. See the [docs for checks](/docs/checks.md) for more info. |
| `env` | `array` | An array of environment variable names that, if set, should be passed to the build and workflow commands. |
| `paths` | `object` | An object containing paths used by `pup`. [See below](#paths). |
diff --git a/src/commands/build.ts b/src/commands/build.ts
index 491c9fd..4f7acfd 100644
--- a/src/commands/build.ts
+++ b/src/commands/build.ts
@@ -1,8 +1,38 @@
import type { Command } from 'commander';
import { getConfig } from '../config.ts';
import { runCommand } from '../utils/process.ts';
+import type { BuildStep, RunCommandResult } from '../types.ts';
import * as output from '../utils/output.ts';
+/**
+ * Runs a single build command, handling the `@` soft-fail prefix.
+ *
+ * @since TBD
+ *
+ * @param {string} step - The command string to execute.
+ * @param {string} cwd - The working directory for the command.
+ *
+ * @returns {Promise<{ cmd: string; bailOnFailure: boolean; result: RunCommandResult }>} The command, bail flag, and result.
+ */
+async function runBuildStep(
+ step: string,
+ cwd: string
+): Promise<{ cmd: string; bailOnFailure: boolean; result: RunCommandResult }> {
+ let cmd = step;
+ let bailOnFailure = true;
+
+ if (cmd.startsWith('@')) {
+ bailOnFailure = false;
+ cmd = cmd.slice(1);
+ }
+
+ output.section(`> ${cmd}`);
+
+ const result = await runCommand(cmd, { cwd });
+
+ return { cmd, bailOnFailure, result };
+}
+
/**
* Registers the `build` command with the CLI program.
*
@@ -20,31 +50,41 @@ export function registerBuildCommand(program: Command): void {
.option('--root
', 'Set the root directory for running commands.')
.action(async (options: { dev?: boolean; root?: string }) => {
const config = getConfig();
- const buildSteps = config.getBuildCommands(options.dev);
+ const buildSteps: BuildStep[] = config.getBuildCommands(options.dev);
const cwd = options.root ?? config.getWorkingDir();
output.log('Running build steps...');
for (const step of buildSteps) {
- let cmd = step;
- let bailOnFailure = true;
+ if (Array.isArray(step)) {
+ // Parallel group: run all commands concurrently
+ const results = await Promise.all(
+ step.map((cmd) => runBuildStep(cmd, cwd))
+ );
- if (cmd.startsWith('@')) {
- bailOnFailure = false;
- cmd = cmd.slice(1);
- }
-
- output.section(`> ${cmd}`);
-
- const result = await runCommand(cmd, {
- cwd,
- });
+ // Check for failures after all parallel commands complete
+ for (const { cmd, bailOnFailure, result } of results) {
+ if (result.exitCode !== 0) {
+ output.error(`[FAIL] Build step failed: ${cmd}`);
+ if (bailOnFailure) {
+ output.error('Exiting...');
+ process.exit(result.exitCode);
+ }
+ }
+ }
+ } else {
+ // Sequential: run single command
+ const { cmd, bailOnFailure, result } = await runBuildStep(
+ step,
+ cwd
+ );
- if (result.exitCode !== 0) {
- output.error(`[FAIL] Build step failed: ${cmd}`);
- if (bailOnFailure) {
- output.error('Exiting...');
- process.exit(result.exitCode);
+ if (result.exitCode !== 0) {
+ output.error(`[FAIL] Build step failed: ${cmd}`);
+ if (bailOnFailure) {
+ output.error('Exiting...');
+ process.exit(result.exitCode);
+ }
}
}
}
diff --git a/src/config.ts b/src/config.ts
index 0ddaa78..5d7f58c 100644
--- a/src/config.ts
+++ b/src/config.ts
@@ -6,6 +6,7 @@ import { WorkflowCollection, createWorkflow } from './models/workflow.ts';
import { PuprcInputSchema, CheckConfigSchema } from './schemas.ts';
import type {
PupConfig,
+ BuildStep,
CheckConfig,
VersionFile,
VersionFileInput,
@@ -225,6 +226,7 @@ export class Config {
const rawWorkflows = this.#config.workflows as unknown;
// Auto-create build workflow
+ // TODO: Add parallel build step support to workflow execution.
if (
this.#config.build?.length > 0 &&
!(rawWorkflows as Record)?.['build']
@@ -337,9 +339,9 @@ export class Config {
*
* @param {boolean} isDev - Whether to return dev build commands.
*
- * @returns {string[]} The list of build command strings.
+ * @returns {BuildStep[]} The list of build steps (strings run sequentially, sub-arrays run in parallel).
*/
- getBuildCommands(isDev = false): string[] {
+ getBuildCommands(isDev = false): BuildStep[] {
if (isDev && this.#config.build_dev?.length > 0) {
return this.#config.build_dev;
}
diff --git a/src/models/workflow.ts b/src/models/workflow.ts
index 42ebb6c..a013ae0 100644
--- a/src/models/workflow.ts
+++ b/src/models/workflow.ts
@@ -1,4 +1,4 @@
-import type { Workflow } from '../types.ts';
+import type { BuildStep, Workflow } from '../types.ts';
/**
* Creates a Workflow object from a slug and list of commands.
@@ -6,11 +6,11 @@ import type { Workflow } from '../types.ts';
* @since TBD
*
* @param {string} slug - The unique identifier for the workflow.
- * @param {string[]} commands - The list of commands to execute in the workflow.
+ * @param {BuildStep[]} commands - The list of build steps to execute in the workflow.
*
* @returns {Workflow} A Workflow object with the provided slug and commands.
*/
-export function createWorkflow(slug: string, commands: string[]): Workflow {
+export function createWorkflow(slug: string, commands: BuildStep[]): Workflow {
return { slug, commands };
}
diff --git a/src/schemas.ts b/src/schemas.ts
index eec7cca..4572ae5 100644
--- a/src/schemas.ts
+++ b/src/schemas.ts
@@ -1,5 +1,15 @@
import { z } from 'zod';
+/**
+ * A build step is either a single command string (run sequentially) or an
+ * array of command strings (run in parallel).
+ *
+ * @since TBD
+ */
+export const BuildStepSchema = z.union([z.string(), z.array(z.string())]);
+
+export type BuildStep = z.infer;
+
/**
* Schema for a version file entry in .puprc paths.versions.
*
@@ -151,8 +161,8 @@ export type PathsConfig = z.infer;
* @since TBD
*/
export const PupConfigSchema = z.object({
- build: z.array(z.string()),
- build_dev: z.array(z.string()),
+ build: z.array(BuildStepSchema),
+ build_dev: z.array(BuildStepSchema),
workflows: z.record(z.string(), z.array(z.string())),
checks: z.record(z.string(), CheckConfigInputSchema),
clean: z.array(z.string()),
@@ -173,8 +183,8 @@ export type PupConfig = z.infer;
* @since TBD
*/
export const PuprcInputSchema = z.object({
- build: z.array(z.string()).optional(),
- build_dev: z.array(z.string()).optional(),
+ build: z.array(BuildStepSchema).optional(),
+ build_dev: z.array(BuildStepSchema).optional(),
workflows: z.record(z.string(), z.array(z.string())).optional(),
checks: z.record(z.string(), CheckConfigInputSchema.or(z.object({}).passthrough())).optional(),
clean: z.array(z.string()).optional(),
@@ -196,7 +206,7 @@ export type PuprcInput = z.infer;
*/
export const WorkflowSchema = z.object({
slug: z.string(),
- commands: z.array(z.string()),
+ commands: z.array(BuildStepSchema),
});
export type Workflow = z.infer;
diff --git a/src/types.ts b/src/types.ts
index fd37b44..d3a3fb2 100644
--- a/src/types.ts
+++ b/src/types.ts
@@ -5,6 +5,7 @@
* @since TBD
*/
export type {
+ BuildStep,
PupConfig,
PuprcInput,
PathsConfig,
@@ -21,6 +22,7 @@ export type {
} from './schemas.ts';
export {
+ BuildStepSchema,
PupConfigSchema,
PuprcInputSchema,
PathsConfigSchema,
diff --git a/tests/commands/build.test.ts b/tests/commands/build.test.ts
index 1975c9d..0d423a3 100644
--- a/tests/commands/build.test.ts
+++ b/tests/commands/build.test.ts
@@ -81,4 +81,70 @@ describe('build command', () => {
expect(result.exitCode).toBe(0);
expect(result.output).toContain('dev build');
});
+
+ it('should run parallel build steps', async () => {
+ const puprc = getPuprc();
+ puprc.build = [['echo "parallel-a"', 'echo "parallel-b"']];
+ writePuprc(puprc, projectDir);
+
+ const result = await runPup('build', { cwd: projectDir });
+ expect(result.exitCode).toBe(0);
+ expect(result.output).toContain('parallel-a');
+ expect(result.output).toContain('parallel-b');
+ });
+
+ it('should run mixed sequential and parallel steps in order', async () => {
+ const puprc = getPuprc();
+ puprc.build = [
+ 'echo "step-1-sequential"',
+ ['echo "step-2-parallel-a"', 'echo "step-2-parallel-b"'],
+ 'echo "step-3-sequential"',
+ ];
+ writePuprc(puprc, projectDir);
+
+ const result = await runPup('build', { cwd: projectDir });
+ expect(result.exitCode).toBe(0);
+ expect(result.output).toContain('step-1-sequential');
+ expect(result.output).toContain('step-2-parallel-a');
+ expect(result.output).toContain('step-2-parallel-b');
+ expect(result.output).toContain('step-3-sequential');
+
+ // Verify sequential ordering: step-1 before parallel group, parallel group before step-3
+ const idx1 = result.output.indexOf('step-1-sequential');
+ const idx2a = result.output.indexOf('step-2-parallel-a');
+ const idx2b = result.output.indexOf('step-2-parallel-b');
+ const idx3 = result.output.indexOf('step-3-sequential');
+ expect(idx1).toBeLessThan(idx2a);
+ expect(idx1).toBeLessThan(idx2b);
+ expect(idx2a).toBeLessThan(idx3);
+ expect(idx2b).toBeLessThan(idx3);
+ });
+
+ it('should handle soft-fail in parallel group', async () => {
+ const puprc = getPuprc();
+ puprc.build = [
+ ['@exit 1', 'echo "parallel-ok"'],
+ 'echo "after-parallel"',
+ ];
+ writePuprc(puprc, projectDir);
+
+ const result = await runPup('build', { cwd: projectDir });
+ expect(result.exitCode).toBe(0);
+ expect(result.output).toContain('parallel-ok');
+ expect(result.output).toContain('after-parallel');
+ });
+
+ it('should fail on non-soft-fail failure in parallel group', async () => {
+ const puprc = getPuprc();
+ puprc.build = [
+ ['exit 1', 'echo "parallel-ok"'],
+ 'echo "should-not-run"',
+ ];
+ writePuprc(puprc, projectDir);
+
+ const result = await runPup('build', { cwd: projectDir });
+ expect(result.exitCode).not.toBe(0);
+ expect(result.output).toContain('parallel-ok');
+ expect(result.output).not.toContain('should-not-run');
+ });
});