Merge branch 'master' into master

Signed-off-by: Justin Bryant <justintbry@gmail.com>
This commit is contained in:
Justin Bryant
2026-05-06 09:49:24 -04:00
committed by GitHub
839 changed files with 27889 additions and 1586 deletions
+35
View File
@@ -1,5 +1,40 @@
# @backstage/plugin-scaffolder-backend
## 3.5.0-next.1
### Minor Changes
- 77bee9f: Updated the `list-scaffolder-tasks` action to support the new "status" filter parameter, allowing the action to return tasks matching a specific status.
- 07e08be: 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.
### Patch Changes
- e9b78e9: Removed the `uuid` dependency and replaced usage with the built-in `crypto.randomUUID()`.
- Updated dependencies
- @backstage/catalog-model@1.8.1-next.1
- @backstage/plugin-catalog-node@2.2.1-next.1
- @backstage/plugin-scaffolder-node@0.13.3-next.1
- @backstage/plugin-permission-common@0.9.9-next.1
## 3.4.1-next.0
### Patch Changes
- Updated dependencies
- @backstage/errors@1.3.1-next.0
- @backstage/integration@2.0.2-next.0
- @backstage/backend-openapi-utils@0.6.9-next.0
- @backstage/backend-plugin-api@1.9.1-next.0
- @backstage/catalog-model@1.8.1-next.0
- @backstage/config@1.3.8-next.0
- @backstage/plugin-catalog-node@2.2.1-next.0
- @backstage/plugin-events-node@0.4.22-next.0
- @backstage/plugin-permission-common@0.9.9-next.0
- @backstage/plugin-permission-node@0.10.13-next.0
- @backstage/plugin-scaffolder-common@2.1.1-next.0
- @backstage/plugin-scaffolder-node@0.13.3-next.0
- @backstage/types@1.2.2
## 3.4.0
### Minor Changes
+1 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@backstage/plugin-scaffolder-backend",
"version": "3.4.0",
"version": "3.5.0-next.1",
"description": "The Backstage backend plugin that helps you create new things",
"backstage": {
"role": "backend-plugin",
@@ -93,7 +93,6 @@
"p-queue": "^6.6.2",
"prom-client": "^15.0.0",
"triple-beam": "^1.4.1",
"uuid": "^11.0.0",
"winston": "^3.2.1",
"winston-transport": "^4.7.0",
"yaml": "^2.0.0",
@@ -63,7 +63,12 @@ describe('createListScaffolderTasksAction', () => {
totalTasks: mockTasks.totalTasks ?? 0,
});
expect(mockScaffolderService.listTasks).toHaveBeenCalledWith(
{ createdBy: undefined, limit: undefined, offset: undefined },
{
createdBy: undefined,
limit: undefined,
offset: undefined,
status: undefined,
},
expect.objectContaining({ credentials: expect.anything() }),
);
});
@@ -106,7 +111,7 @@ describe('createListScaffolderTasksAction', () => {
});
expect(mockScaffolderService.listTasks).toHaveBeenCalledWith(
{ createdBy: undefined, limit: 2, offset: 1 },
{ createdBy: undefined, limit: 2, offset: 1, status: undefined },
expect.objectContaining({ credentials: expect.anything() }),
);
@@ -188,11 +193,102 @@ describe('createListScaffolderTasksAction', () => {
createdBy: 'user:default/alice',
limit: undefined,
offset: undefined,
status: undefined,
},
expect.objectContaining({ credentials: expect.anything() }),
);
});
it('should filter tasks by a single status when status is provided', async () => {
const mockActionsRegistry = actionsRegistryServiceMock();
const mockAuth = mockServices.auth.mock();
const mockScaffolderService = scaffolderServiceMock.mock();
const completedTasks = generateMockTasks().tasks.filter(
t => t.status === 'completed',
);
mockScaffolderService.listTasks.mockResolvedValue({
items: completedTasks as ScaffolderTask[],
totalItems: completedTasks.length,
});
createListScaffolderTasksAction({
actionsRegistry: mockActionsRegistry,
auth: mockAuth,
scaffolderService: mockScaffolderService,
});
const result = await mockActionsRegistry.invoke({
id: 'test:list-scaffolder-tasks',
input: { status: 'completed' },
});
expect(mockScaffolderService.listTasks).toHaveBeenCalledWith(
{
createdBy: undefined,
limit: undefined,
offset: undefined,
status: 'completed',
},
expect.objectContaining({ credentials: expect.anything() }),
);
expect(result.output).toEqual({
tasks: completedTasks.map(task => ({
id: task.id,
spec: task.spec,
status: task.status,
createdAt: task.createdAt,
lastHeartbeatAt: task.lastHeartbeatAt,
})),
totalTasks: completedTasks.length,
});
});
it('should filter tasks by multiple statuses when an array is provided', async () => {
const mockActionsRegistry = actionsRegistryServiceMock();
const mockAuth = mockServices.auth.mock();
const mockScaffolderService = scaffolderServiceMock.mock();
const matchingTasks = generateMockTasks().tasks.filter(
t => t.status === 'completed' || t.status === 'failed',
);
mockScaffolderService.listTasks.mockResolvedValue({
items: matchingTasks as ScaffolderTask[],
totalItems: matchingTasks.length,
});
createListScaffolderTasksAction({
actionsRegistry: mockActionsRegistry,
auth: mockAuth,
scaffolderService: mockScaffolderService,
});
const result = await mockActionsRegistry.invoke({
id: 'test:list-scaffolder-tasks',
input: { status: ['completed', 'failed'] },
});
expect(mockScaffolderService.listTasks).toHaveBeenCalledWith(
{
createdBy: undefined,
limit: undefined,
offset: undefined,
status: ['completed', 'failed'],
},
expect.objectContaining({ credentials: expect.anything() }),
);
expect(result.output).toEqual({
tasks: matchingTasks.map(task => ({
id: task.id,
spec: task.spec,
status: task.status,
createdAt: task.createdAt,
lastHeartbeatAt: task.lastHeartbeatAt,
})),
totalTasks: matchingTasks.length,
});
});
it('should throw NotAllowedError when owned is true without user identity', async () => {
const mockActionsRegistry = actionsRegistryServiceMock();
const mockAuth = mockServices.auth.mock();
@@ -40,7 +40,7 @@ This allows you to list scaffolder tasks that have been created.
Each task has a unique id, specification, and status (one of open, processing, completed, failed, cancelled, skipped).
Each task includes a timestamp for when it was created, and an optional last heartbeat timestamp indicating the most recent activity.
Set owned to true to return only tasks created by the current user; omit or set to false for all tasks the credentials can see.
Pagination is supported via limit and offset.
Filtering by one or multiple statuses is supported. Pagination is supported via limit and offset.
`,
schema: {
input: z =>
@@ -65,6 +65,20 @@ Pagination is supported via limit and offset.
.min(0)
.describe('The offset to start from for pagination')
.optional(),
status: (() => {
const statusEnum = z.enum([
'open',
'processing',
'completed',
'failed',
'cancelled',
'skipped',
]);
return z
.union([statusEnum, z.array(statusEnum).nonempty()])
.optional()
.describe('Filter tasks by status, or an array of statuses');
})(),
}),
output: z =>
z
@@ -112,6 +126,7 @@ Pagination is supported via limit and offset.
createdBy,
limit: input.limit,
offset: input.offset,
status: input.status,
},
{ credentials },
);
@@ -42,7 +42,7 @@ import { JsonObject } from '@backstage/types';
import fs from 'fs-extra';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { v4 as uuid } from 'uuid';
import { randomUUID as uuid } from 'node:crypto';
import { NunjucksWorkflowRunner } from '../tasks/NunjucksWorkflowRunner';
import { DecoratedActionsRegistry } from './DecoratedActionsRegistry';
import { TemplateActionRegistry } from '../actions';
@@ -21,7 +21,7 @@ import {
} from '@backstage/backend-plugin-api';
import { ConflictError, NotFoundError } from '@backstage/errors';
import { Knex } from 'knex';
import { v4 as uuid } from 'uuid';
import { randomUUID as uuid } from 'node:crypto';
import {
TaskStore,
TaskStoreCreateTaskOptions,
@@ -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,14 +644,29 @@ export class NunjucksWorkflowRunner implements WorkflowRunner {
this.environment = await this.getEnvironmentConfig();
const { render: renderTemplate, dispose } =
await SecureTemplater.loadRenderer({
templateFilters: {
...this.defaultTemplateFilters,
...additionalTemplateFilters,
// 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 { render: renderTemplate, dispose } = await SecureTemplater.loadRenderer({
templateFilters: {
...this.defaultTemplateFilters,
...additionalTemplateFilters,
},
templateGlobals: {
...additionalTemplateGlobals,
always: () => {
statusCheckInvoked.value = true;
return true;
},
templateGlobals: additionalTemplateGlobals,
});
failure: () => {
statusCheckInvoked.value = true;
return taskState.failed;
},
},
});
try {
await task.rehydrateWorkspace?.({ taskId, targetPath: workspacePath });
@@ -682,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);
@@ -84,7 +84,7 @@ import { HumanDuration, JsonObject } from '@backstage/types';
import express from 'express';
import { Duration } from 'luxon';
import { pathToFileURL } from 'node:url';
import { v4 as uuid } from 'uuid';
import { randomUUID as uuid } from 'node:crypto';
import { z } from 'zod/v3';
import {
DatabaseTaskStore,