Skip to content
Draft
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
39 changes: 39 additions & 0 deletions docs/commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
4 changes: 2 additions & 2 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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). |
Expand Down
76 changes: 58 additions & 18 deletions src/commands/build.ts
Original file line number Diff line number Diff line change
@@ -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.
*
Expand All @@ -20,31 +50,41 @@ export function registerBuildCommand(program: Command): void {
.option('--root <dir>', '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);
}
}
}
}
Expand Down
6 changes: 4 additions & 2 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { WorkflowCollection, createWorkflow } from './models/workflow.ts';
import { PuprcInputSchema, CheckConfigSchema } from './schemas.ts';
import type {
PupConfig,
BuildStep,
CheckConfig,
VersionFile,
VersionFileInput,
Expand Down Expand Up @@ -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<string, unknown>)?.['build']
Expand Down Expand Up @@ -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;
}
Expand Down
6 changes: 3 additions & 3 deletions src/models/workflow.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,16 @@
import type { Workflow } from '../types.ts';
import type { BuildStep, Workflow } from '../types.ts';

/**
* Creates a Workflow object from a slug and list of commands.
*
* @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 };
}

Expand Down
20 changes: 15 additions & 5 deletions src/schemas.ts
Original file line number Diff line number Diff line change
@@ -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<typeof BuildStepSchema>;

/**
* Schema for a version file entry in .puprc paths.versions.
*
Expand Down Expand Up @@ -151,8 +161,8 @@ export type PathsConfig = z.infer<typeof PathsConfigSchema>;
* @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()),
Expand All @@ -173,8 +183,8 @@ export type PupConfig = z.infer<typeof PupConfigSchema>;
* @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(),
Expand All @@ -196,7 +206,7 @@ export type PuprcInput = z.infer<typeof PuprcInputSchema>;
*/
export const WorkflowSchema = z.object({
slug: z.string(),
commands: z.array(z.string()),
commands: z.array(BuildStepSchema),
});

export type Workflow = z.infer<typeof WorkflowSchema>;
Expand Down
2 changes: 2 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
* @since TBD
*/
export type {
BuildStep,
PupConfig,
PuprcInput,
PathsConfig,
Expand All @@ -21,6 +22,7 @@ export type {
} from './schemas.ts';

export {
BuildStepSchema,
PupConfigSchema,
PuprcInputSchema,
PathsConfigSchema,
Expand Down
66 changes: 66 additions & 0 deletions tests/commands/build.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
});
});