feat: Add status check functions for scaffolder steps (#32890)

* feat: Add status check functions for scaffolder steps

- Introduced `always()` and `failure()` functions to control step execution after failures.
- Updated documentation to explain usage of new status check functions.
- Enhanced NunjucksWorkflowRunner to process these functions in step conditions.
- Added tests to verify behavior of steps using `always()` and `failure()`.

Signed-off-by: ferin79 <ferinpatel79@gmail.com>

* feat: Enhance status check functions in scaffolder steps

- Updated documentation to clarify usage of status check functions with template expressions.
- Modified tests to reflect changes in syntax for status checks.
- Refactored NunjucksWorkflowRunner to ensure proper handling of status check functions in step conditions.

Signed-off-by: ferin79 <ferinpatel79@gmail.com>

* docs: Clarify usage of status check functions in writing templates

- Removed redundant explanation about truthy conditions after step failure.
- Streamlined the description for better clarity on status check functions.

Signed-off-by: ferin79 <ferinpatel79@gmail.com>

---------

Signed-off-by: ferin79 <ferinpatel79@gmail.com>
This commit is contained in:
Ferin Patel
2026-04-28 06:16:59 -03:00
committed by GitHub
parent ffae2d437c
commit 07e08beac1
4 changed files with 508 additions and 10 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-scaffolder-backend': minor
---
Added `always()` and `failure()` status check functions for scaffolder steps. These functions can be used in the if field of a step to control execution after failures. `always()` ensures a step runs regardless of previous step outcomes, while `failure()` runs a step only when a previous step has failed.
@@ -746,6 +746,75 @@ input:
When `each` is used, the outputs of a repeated step are returned as an array of outputs from each iteration.
### Status Check Functions - `always()` and `failure()`
By default, when a step fails during a scaffolder run, all subsequent steps are skipped and the task is marked as failed. This can be problematic when your template creates external resources (repositories, cloud infrastructure, deployments) that need to be cleaned up if a later step fails.
Status check functions give you control over which steps run even after a failure. You use them inside a `${{ ... }}` template expression in the `if` field of a step.
| Function | Description |
| ----------- | ---------------------------------------------------------------------------- |
| `always()` | Always runs the step, regardless of whether previous steps passed or failed. |
| `failure()` | Runs the step only when a previous step has failed. |
These functions must be used as template expressions such as `${{ always() }}` or `${{ failure() }}`.
After a step has failed, the scaffolder only attempts later steps whose `if` expression invokes one of these status check functions.
#### Usage
```yaml
steps:
- id: cleanup
name: Cleanup Resources
action: my:cleanup:action
if: ${{ always() }}
```
#### Example: Cleanup on failure
A common pattern is to create resources in early steps and add cleanup steps
that only run if something goes wrong:
```yaml
steps:
- id: create-repo
name: Create Repository
action: publish:github
input:
repoUrl: ${{ parameters.repoUrl }}
- id: deploy
name: Deploy to Kubernetes
action: deploy:kubernetes
input:
manifest: ./k8s/deployment.yaml
# Only runs when a previous step failed — cleans up the repository
- id: cleanup-repo
name: Delete Repository
action: github:repo:delete
if: ${{ failure() }}
input:
repoUrl: ${{ parameters.repoUrl }}
# Always runs — post an audit event regardless of outcome
- id: audit
name: Post Audit Event
action: debug:log
if: ${{ always() }}
input:
message: 'Scaffolder run completed for ${{ parameters.repoUrl }}'
# Does not run after a failure, because it does not invoke a status check function
- id: plain-truthy-condition
name: Plain Truthy Condition
action: debug:log
if: ${{ true }}
input:
message: 'This step is skipped after a previous failure'
```
## Outputs
Each individual step can output some variables that can be used in the
@@ -2297,4 +2297,351 @@ describe('NunjucksWorkflowRunner', () => {
expect(mockedPermissionApi.authorizeConditional).toHaveBeenCalledTimes(1);
});
});
describe('step status check functions (always/failure)', () => {
let failingHandler: jest.Mock;
let cleanupHandler: jest.Mock;
beforeEach(() => {
failingHandler = jest.fn().mockRejectedValue(new Error('step failed'));
cleanupHandler = jest.fn();
actionRegistry.register(
createTemplateAction({
id: 'failing-action',
description: 'Action that always fails',
handler: failingHandler,
}),
);
actionRegistry.register(
createTemplateAction({
id: 'cleanup-action',
description: 'Cleanup action',
handler: cleanupHandler,
}),
);
});
it('should run step with if: ${{ always() }} even when a previous step failed', async () => {
const task = createMockTaskWithSpec({
steps: [
{
id: 'step1',
name: 'Failing step',
action: 'failing-action',
},
{
id: 'step2',
name: 'Always runs',
action: 'cleanup-action',
if: '${{ always() }}',
},
],
});
await expect(runner.execute(task)).rejects.toThrow('step failed');
expect(cleanupHandler).toHaveBeenCalledTimes(1);
});
it('should run step with if: ${{ failure() }} only when a previous step failed', async () => {
const task = createMockTaskWithSpec({
steps: [
{
id: 'step1',
name: 'Failing step',
action: 'failing-action',
},
{
id: 'step2',
name: 'Runs on failure',
action: 'cleanup-action',
if: '${{ failure() }}',
},
],
});
await expect(runner.execute(task)).rejects.toThrow('step failed');
expect(cleanupHandler).toHaveBeenCalledTimes(1);
});
it('should not run step with if: ${{ failure() }} when no step has failed', async () => {
const task = createMockTaskWithSpec({
steps: [
{
id: 'step1',
name: 'Succeeding step',
action: 'jest-mock-action',
},
{
id: 'step2',
name: 'Only on failure',
action: 'cleanup-action',
if: '${{ failure() }}',
},
],
});
await runner.execute(task);
expect(fakeActionHandler).toHaveBeenCalledTimes(1);
expect(cleanupHandler).not.toHaveBeenCalled();
});
it('should not run step with if: ${{ true }} after a previous step failed', async () => {
const task = createMockTaskWithSpec({
steps: [
{
id: 'step1',
name: 'Failing step',
action: 'failing-action',
},
{
id: 'step2',
name: 'Truthy but not a status check',
action: 'cleanup-action',
if: '${{ true }}',
},
],
});
await expect(runner.execute(task)).rejects.toThrow('step failed');
expect(cleanupHandler).not.toHaveBeenCalled();
});
it('should still throw the original error after running ${{ always() }} steps', async () => {
const task = createMockTaskWithSpec({
steps: [
{
id: 'step1',
name: 'Failing step',
action: 'failing-action',
},
{
id: 'step2',
name: 'Always step',
action: 'cleanup-action',
if: '${{ always() }}',
},
{
id: 'step3',
name: 'Should be skipped',
action: 'jest-mock-action',
},
],
});
await expect(runner.execute(task)).rejects.toThrow('step failed');
expect(cleanupHandler).toHaveBeenCalledTimes(1);
// step3 should not run because it has no status check function
expect(fakeActionHandler).not.toHaveBeenCalled();
});
it('should continue running always() steps even if a cleanup step also fails', async () => {
const failingCleanup = jest
.fn()
.mockRejectedValue(new Error('cleanup failed'));
actionRegistry.register(
createTemplateAction({
id: 'failing-cleanup',
description: 'Failing cleanup',
handler: failingCleanup,
}),
);
const task = createMockTaskWithSpec({
steps: [
{
id: 'step1',
name: 'Failing step',
action: 'failing-action',
},
{
id: 'step2',
name: 'Failing cleanup',
action: 'failing-cleanup',
if: '${{ always() }}',
},
{
id: 'step3',
name: 'Another cleanup',
action: 'cleanup-action',
if: '${{ always() }}',
},
],
});
// Should throw the first error (from step1)
await expect(runner.execute(task)).rejects.toThrow('step failed');
expect(failingCleanup).toHaveBeenCalledTimes(1);
expect(cleanupHandler).toHaveBeenCalledTimes(1);
});
it('should log all errors when multiple cleanup steps fail', async () => {
const secondCleanupError = new Error('second cleanup failed');
const thirdCleanupError = new Error('third cleanup failed');
const failingCleanup2 = jest.fn().mockRejectedValue(secondCleanupError);
const failingCleanup3 = jest.fn().mockRejectedValue(thirdCleanupError);
actionRegistry.register(
createTemplateAction({
id: 'failing-cleanup-2',
description: 'Second failing cleanup',
handler: failingCleanup2,
}),
);
actionRegistry.register(
createTemplateAction({
id: 'failing-cleanup-3',
description: 'Third failing cleanup',
handler: failingCleanup3,
}),
);
const task = createMockTaskWithSpec({
steps: [
{
id: 'step1',
name: 'Failing step',
action: 'failing-action',
},
{
id: 'step2',
name: 'First cleanup',
action: 'failing-cleanup-2',
if: '${{ always() }}',
},
{
id: 'step3',
name: 'Second cleanup',
action: 'failing-cleanup-3',
if: '${{ always() }}',
},
],
});
// Should throw the first error (from step1)
await expect(runner.execute(task)).rejects.toThrow('step failed');
// All cleanup handlers should have been called
expect(failingCleanup2).toHaveBeenCalledTimes(1);
expect(failingCleanup3).toHaveBeenCalledTimes(1);
// Subsequent errors should be logged
expect(logger.error).toHaveBeenCalledWith(
'Additional error in step step2 (First cleanup): second cleanup failed',
secondCleanupError,
);
expect(logger.error).toHaveBeenCalledWith(
'Additional error in step step3 (Second cleanup): third cleanup failed',
thirdCleanupError,
);
// Summary warning should be logged
expect(logger.warn).toHaveBeenCalledWith(
expect.stringContaining(
'Task failed with 3 errors. First error from step step1. Additional failures in: step2 (First cleanup), step3 (Second cleanup)',
),
);
// Task logs should contain additional error information
expect(fakeTaskLog).toHaveBeenCalledWith(
expect.stringContaining('Additional error occurred'),
{ stepId: 'step2', status: 'failed' },
);
expect(fakeTaskLog).toHaveBeenCalledWith(
expect.stringContaining('Additional error occurred'),
{ stepId: 'step3', status: 'failed' },
);
});
it('should support failure() and always() together across multiple steps', async () => {
const task = createMockTaskWithSpec({
steps: [
{
id: 'step1',
name: 'First step',
action: 'jest-mock-action',
},
{
id: 'step2',
name: 'Should skip with template failure',
action: 'cleanup-action',
if: '${{ failure() }}',
},
{
id: 'step3',
name: 'Should run with template always',
action: 'cleanup-action',
if: '${{ always() }}',
},
{
id: 'step4',
name: 'Failing step',
action: 'failing-action',
},
{
id: 'step5',
name: 'Should run with template failure after error',
action: 'cleanup-action',
if: '${{ failure() }}',
},
{
id: 'step6',
name: 'Should run with template always after error',
action: 'cleanup-action',
if: '${{ always() }}',
},
],
});
await expect(runner.execute(task)).rejects.toThrow('step failed');
// Verify execution order and counts
expect(fakeActionHandler).toHaveBeenCalledTimes(1); // step1
expect(cleanupHandler).toHaveBeenCalledTimes(3); // step3, step5, step6
// Verify the correct steps ran in the right order
const taskLogCalls = fakeTaskLog.mock.calls.map(args =>
stripAnsi(args[0]),
);
// step1 should run
expect(taskLogCalls).toContain('Beginning step First step');
expect(taskLogCalls).toContain('Finished step First step');
// step2 should be skipped (no failure yet)
expect(taskLogCalls).toContain(
'Skipping step step2 because its if condition was false',
);
// step3 should run (always)
expect(taskLogCalls).toContain(
'Beginning step Should run with template always',
);
expect(taskLogCalls).toContain(
'Finished step Should run with template always',
);
// step4 should fail
expect(taskLogCalls).toContain('Beginning step Failing step');
// step5 should run (failure condition met)
expect(taskLogCalls).toContain(
'Beginning step Should run with template failure after error',
);
expect(taskLogCalls).toContain(
'Finished step Should run with template failure after error',
);
// step6 should run (always)
expect(taskLogCalls).toContain(
'Beginning step Should run with template always after error',
);
expect(taskLogCalls).toContain(
'Finished step Should run with template always after error',
);
});
});
});
@@ -644,12 +644,28 @@ export class NunjucksWorkflowRunner implements WorkflowRunner {
this.environment = await this.getEnvironmentConfig();
// Track whether any step has failed, used by status check functions
const taskState = { failed: false };
// Track whether a status check global (always/failure) was invoked during rendering
const statusCheckInvoked = { value: false };
const renderTemplate = await SecureTemplater.loadRenderer({
templateFilters: {
...this.defaultTemplateFilters,
...additionalTemplateFilters,
},
templateGlobals: additionalTemplateGlobals,
templateGlobals: {
...additionalTemplateGlobals,
always: () => {
statusCheckInvoked.value = true;
return true;
},
failure: () => {
statusCheckInvoked.value = true;
return taskState.failed;
},
},
});
try {
@@ -681,16 +697,77 @@ export class NunjucksWorkflowRunner implements WorkflowRunner {
)
: [{ result: AuthorizeResult.ALLOW }];
let firstError: Error | undefined;
const allErrors: Array<{ step: TaskStep; error: Error }> = [];
for (const step of task.spec.steps) {
await this.executeStep(
task,
step,
context,
renderTemplate,
taskTrack,
workspacePath,
decision,
);
// If a previous step failed, only run steps whose `if` condition
// invokes a status check global (${{ always() }} or ${{ failure() }})
if (taskState.failed) {
if (typeof step.if !== 'string') {
await task.emitLog(
`Skipping step ${step.id} because a previous step failed`,
{ stepId: step.id, status: 'skipped' },
);
continue;
}
// Render the if condition to detect status check function usage
statusCheckInvoked.value = false;
this.render(step.if, context, renderTemplate);
if (!statusCheckInvoked.value) {
await task.emitLog(
`Skipping step ${step.id} because a previous step failed`,
{ stepId: step.id, status: 'skipped' },
);
continue;
}
}
try {
await this.executeStep(
task,
step,
context,
renderTemplate,
taskTrack,
workspacePath,
decision,
);
} catch (err) {
const error = err as Error;
allErrors.push({ step, error });
if (!firstError) {
firstError = error;
} else {
// Log subsequent errors to preserve debugging information
this.options.logger.error(
`Additional error in step ${step.id} (${step.name}): ${error.message}`,
error,
);
await task.emitLog(
`Additional error occurred: ${error.message}\n${error.stack}`,
{ stepId: step.id, status: 'failed' },
);
}
taskState.failed = true;
}
}
if (firstError) {
// If there were multiple errors, add context to the first error
if (allErrors.length > 1) {
const additionalErrorSummary = allErrors
.slice(1)
.map(({ step }) => `${step.id} (${step.name})`)
.join(', ');
this.options.logger.warn(
`Task failed with ${allErrors.length} errors. First error from step ${allErrors[0].step.id}. Additional failures in: ${additionalErrorSummary}`,
);
}
throw firstError;
}
const output = this.render(task.spec.output, context, renderTemplate);