diff --git a/.changeset/lemon-things-cheat.md b/.changeset/lemon-things-cheat.md new file mode 100644 index 0000000000..dcf2b5ea5e --- /dev/null +++ b/.changeset/lemon-things-cheat.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-scaffolder-backend': patch +--- + +Support `if` conditions inside `each` loops for scaffolder steps diff --git a/plugins/scaffolder-backend/src/scaffolder/tasks/NunjucksWorkflowRunner.test.ts b/plugins/scaffolder-backend/src/scaffolder/tasks/NunjucksWorkflowRunner.test.ts index 07a25b9e0e..530835e434 100644 --- a/plugins/scaffolder-backend/src/scaffolder/tasks/NunjucksWorkflowRunner.test.ts +++ b/plugins/scaffolder-backend/src/scaffolder/tasks/NunjucksWorkflowRunner.test.ts @@ -1066,6 +1066,50 @@ describe('NunjucksWorkflowRunner', () => { } }); + it('should run a step repeatedly - only iterations where the "if" condition is truthy', async () => { + const truthyConditions = [true, 1, 'a', {}]; + const falsyConditions = [false, 0, null, '']; + const conditions = [...truthyConditions, ...falsyConditions]; + const task = createMockTaskWithSpec({ + steps: [ + { + id: 'test', + name: 'name', + each: '${{parameters.conditions}}', + action: 'jest-mock-action', + input: { condition: '${{each.value}}' }, + if: '${{each.value}}', + }, + ], + parameters: { + conditions, + }, + }); + await runner.execute(task); + + truthyConditions.forEach((condition, idx) => { + expectTaskLog( + `info: Running step each: {"key":"${idx}","value":"${condition}"}`, + ); + expect(fakeActionHandler).toHaveBeenCalledWith( + expect.objectContaining({ input: { condition } }), + ); + }); + + falsyConditions.forEach((condition, idx) => { + expectTaskLog( + `info: Skipping step each: {"key":"${ + idx + truthyConditions.length + }","value":"${condition}"}`, + ); + expect(fakeActionHandler).not.toHaveBeenCalledWith( + expect.objectContaining({ input: { condition } }), + ); + }); + + expect(fakeActionHandler).toHaveBeenCalledTimes(truthyConditions.length); + }); + it('should run a step repeatedly with validation of single-expression value', async () => { const numbers = [5, 7, 9]; const task = createMockTaskWithSpec({ diff --git a/plugins/scaffolder-backend/src/scaffolder/tasks/NunjucksWorkflowRunner.ts b/plugins/scaffolder-backend/src/scaffolder/tasks/NunjucksWorkflowRunner.ts index 3b31450329..0700c58bf8 100644 --- a/plugins/scaffolder-backend/src/scaffolder/tasks/NunjucksWorkflowRunner.ts +++ b/plugins/scaffolder-backend/src/scaffolder/tasks/NunjucksWorkflowRunner.ts @@ -270,6 +270,7 @@ export class NunjucksWorkflowRunner implements WorkflowRunner { if ( step.if === false || (typeof step.if === 'string' && + step.each === undefined && !isTruthy(this.render(step.if, context, renderTemplate))) ) { await stepTrack.skipFalsy(); @@ -340,20 +341,18 @@ export class NunjucksWorkflowRunner implements WorkflowRunner { } } + const preIterationContext = { + ...context, + environment: { + parameters: this.environment?.parameters ?? {}, + secrets: this.environment?.secrets ?? {}, + }, + secrets: task.secrets ?? {}, + }; + const resolvedEach = step.each && - this.render( - step.each, - { - ...context, - environment: { - parameters: this.environment?.parameters || {}, - secrets: this.environment?.secrets ?? {}, - }, - secrets: task?.secrets ?? {}, - }, - renderTemplate, - ); + this.render(step.each, preIterationContext, renderTemplate); if (step.each && !resolvedEach) { throw new InputError( @@ -367,26 +366,29 @@ export class NunjucksWorkflowRunner implements WorkflowRunner { each: { key, value }, })) : [{}] - ).map(i => ({ - ...i, - // Secrets are only passed when templating the input to actions for security reasons - input: step.input - ? this.render( - step.input, - { - ...context, - environment: { - parameters: this.environment?.parameters ?? {}, - secrets: this.environment?.secrets ?? {}, - }, - secrets: task.secrets ?? {}, - ...i, - }, - renderTemplate, - ) - : {}, - })); + ).map(i => { + const fullContext = { ...preIterationContext, ...i }; + // Evaluate if condition once per iteration, only when using 'each' + const shouldRun = + !('each' in i) || + !step.if || + isTruthy(this.render(step.if, fullContext, renderTemplate)); + + return { + ...i, + shouldRun, + // Secrets are only passed when templating the input to actions for security reasons + input: step.input + ? this.render(step.input, fullContext, renderTemplate) + : {}, + }; + }); for (const iteration of iterations) { + if (!iteration.shouldRun) { + // No need to check schema or authorization for iterations that will not run + continue; + } + const actionId = `${action.id}${ iteration.each ? `[${iteration.each.key}]` : '' }`; @@ -424,10 +426,20 @@ export class NunjucksWorkflowRunner implements WorkflowRunner { for (const iteration of iterations) { if (iteration.each) { + if (!iteration.shouldRun) { + taskLogger.info( + `Skipping step each: ${JSON.stringify( + iteration.each, + (k, v) => (k ? String(v) : v), + 0, + )}`, + ); + continue; + } taskLogger.info( `Running step each: ${JSON.stringify( iteration.each, - (k, v) => (k ? v.toString() : v), + (k, v) => (k ? String(v) : v), 0, )}`, );