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'); + }); });