From 6e295a0913347e73ac4f8059613399919d12ce63 Mon Sep 17 00:00:00 2001 From: letthem Date: Wed, 4 Mar 2026 15:21:40 +0900 Subject: [PATCH 001/434] feat: Add AWS Cost Insights plugin to marketplace Signed-off-by: letthem --- microsite/data/plugins/aws-cost-insights.yaml | 10 ++++++++++ .../static/img/aws-cost-insights-logo.svg | 20 +++++++++++++++++++ 2 files changed, 30 insertions(+) create mode 100644 microsite/data/plugins/aws-cost-insights.yaml create mode 100644 microsite/static/img/aws-cost-insights-logo.svg diff --git a/microsite/data/plugins/aws-cost-insights.yaml b/microsite/data/plugins/aws-cost-insights.yaml new file mode 100644 index 0000000000..cf62bcb914 --- /dev/null +++ b/microsite/data/plugins/aws-cost-insights.yaml @@ -0,0 +1,10 @@ +--- +title: AWS Cost Insights +author: letthem +authorUrl: https://github.com/letthem +category: FinOps +description: Visualize AWS EC2 costs from S3 CUR data with resource insights and cost forecasting. +documentation: https://github.com/letthem/backstage-plugin-cost-insights +iconUrl: /img/aws-cost-insights-logo.svg +npmPackageName: '@letthem/backstage-plugin-aws-cost-insights' +addedDate: '2026-03-04' diff --git a/microsite/static/img/aws-cost-insights-logo.svg b/microsite/static/img/aws-cost-insights-logo.svg new file mode 100644 index 0000000000..f901cf6c03 --- /dev/null +++ b/microsite/static/img/aws-cost-insights-logo.svg @@ -0,0 +1,20 @@ + + + + + + +
+ + + + +
+ + + + + + + +
From 478cafe9928b10f95ea5a0ae8254912b47a8d1ff Mon Sep 17 00:00:00 2001 From: letthem Date: Wed, 4 Mar 2026 17:11:00 +0900 Subject: [PATCH 002/434] microsite: add status field to AWS Cost Insights plugin Signed-off-by: letthem --- microsite/data/plugins/aws-cost-insights.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/microsite/data/plugins/aws-cost-insights.yaml b/microsite/data/plugins/aws-cost-insights.yaml index cf62bcb914..623e4c05f6 100644 --- a/microsite/data/plugins/aws-cost-insights.yaml +++ b/microsite/data/plugins/aws-cost-insights.yaml @@ -8,3 +8,4 @@ documentation: https://github.com/letthem/backstage-plugin-cost-insights iconUrl: /img/aws-cost-insights-logo.svg npmPackageName: '@letthem/backstage-plugin-aws-cost-insights' addedDate: '2026-03-04' +status: active From 77bee9f0134b9107019f38ce9d52af4d59b362b0 Mon Sep 17 00:00:00 2001 From: John Collier Date: Wed, 4 Mar 2026 16:25:29 -0500 Subject: [PATCH 003/434] feat(scaffolder): Allow sorting by status in scaffolderService.listTasks. Added optional `status` filter to `ScaffolderService.listTasks`, by exposing the `status` query parameter, allowing callers to retrieve tasks of a specific status. Also updated the `list-scaffolder-tasks` action to support this parameter. Signed-off-by: John Collier --- .changeset/fifty-clubs-play.md | 5 ++ .changeset/gold-friends-end.md | 5 ++ .../actions/listScaffolderTasksAction.test.ts | 55 ++++++++++++++++++- .../src/actions/listScaffolderTasksAction.ts | 12 ++++ .../scaffolder-node/src/scaffolderService.ts | 5 ++ 5 files changed, 80 insertions(+), 2 deletions(-) create mode 100644 .changeset/fifty-clubs-play.md create mode 100644 .changeset/gold-friends-end.md diff --git a/.changeset/fifty-clubs-play.md b/.changeset/fifty-clubs-play.md new file mode 100644 index 0000000000..561e24a130 --- /dev/null +++ b/.changeset/fifty-clubs-play.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-scaffolder-backend': minor +--- + +Updated the `list-scaffolder-tasks` action to support the new "status" filter paramter, allowing the action to return tasks matching a specific status. diff --git a/.changeset/gold-friends-end.md b/.changeset/gold-friends-end.md new file mode 100644 index 0000000000..eddacadf19 --- /dev/null +++ b/.changeset/gold-friends-end.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-scaffolder-node': minor +--- + +Added optional `status` filter to `ScaffolderService.listTasks`, allowing callers to retrieve tasks matching a specific status. diff --git a/plugins/scaffolder-backend/src/actions/listScaffolderTasksAction.test.ts b/plugins/scaffolder-backend/src/actions/listScaffolderTasksAction.test.ts index 075ba919f1..bad0b366b6 100644 --- a/plugins/scaffolder-backend/src/actions/listScaffolderTasksAction.test.ts +++ b/plugins/scaffolder-backend/src/actions/listScaffolderTasksAction.test.ts @@ -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,57 @@ describe('createListScaffolderTasksAction', () => { createdBy: 'user:default/alice', limit: undefined, offset: undefined, + status: undefined, }, expect.objectContaining({ credentials: expect.anything() }), ); }); + it('should filter tasks by 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 throw NotAllowedError when owned is true without user identity', async () => { const mockActionsRegistry = actionsRegistryServiceMock(); const mockAuth = mockServices.auth.mock(); diff --git a/plugins/scaffolder-backend/src/actions/listScaffolderTasksAction.ts b/plugins/scaffolder-backend/src/actions/listScaffolderTasksAction.ts index 70977c9d12..2b05b8aee1 100644 --- a/plugins/scaffolder-backend/src/actions/listScaffolderTasksAction.ts +++ b/plugins/scaffolder-backend/src/actions/listScaffolderTasksAction.ts @@ -65,6 +65,17 @@ Pagination is supported via limit and offset. .min(0) .describe('The offset to start from for pagination') .optional(), + status: z + .enum([ + 'open', + 'processing', + 'completed', + 'failed', + 'cancelled', + 'skipped', + ]) + .optional() + .describe('Filter tasks by status'), }), output: z => z @@ -112,6 +123,7 @@ Pagination is supported via limit and offset. createdBy, limit: input.limit, offset: input.offset, + status: input.status, }, { credentials }, ); diff --git a/plugins/scaffolder-node/src/scaffolderService.ts b/plugins/scaffolder-node/src/scaffolderService.ts index b978164c7f..74d301b718 100644 --- a/plugins/scaffolder-node/src/scaffolderService.ts +++ b/plugins/scaffolder-node/src/scaffolderService.ts @@ -83,6 +83,7 @@ export interface ScaffolderService { createdBy?: string; limit?: number; offset?: number; + status?: ScaffolderTaskStatus; }, options: ScaffolderServiceRequestOptions, ): Promise<{ items: ScaffolderTask[]; totalItems: number }>; @@ -185,6 +186,7 @@ class DefaultScaffolderService implements ScaffolderService { createdBy?: string; limit?: number; offset?: number; + status?: ScaffolderTaskStatus; }, options: ScaffolderServiceRequestOptions, ): Promise<{ items: ScaffolderTask[]; totalItems: number }> { @@ -201,6 +203,9 @@ class DefaultScaffolderService implements ScaffolderService { if (request.offset !== undefined) { params.set('offset', String(request.offset)); } + if (request.status !== undefined) { + params.set('status', request.status); + } const query = params.toString(); const url = `${baseUrl}/v2/tasks${query ? `?${query}` : ''}`; From 2375ad9a832617ea3aad9009f0797e57b9fe233b Mon Sep 17 00:00:00 2001 From: John Collier Date: Wed, 4 Mar 2026 16:42:46 -0500 Subject: [PATCH 004/434] Generate api reports Signed-off-by: John Collier --- plugins/scaffolder-node/report-testUtils.api.md | 1 + plugins/scaffolder-node/report.api.md | 1 + 2 files changed, 2 insertions(+) diff --git a/plugins/scaffolder-node/report-testUtils.api.md b/plugins/scaffolder-node/report-testUtils.api.md index e4f9c55ee2..51bc0d0d5e 100644 --- a/plugins/scaffolder-node/report-testUtils.api.md +++ b/plugins/scaffolder-node/report-testUtils.api.md @@ -80,6 +80,7 @@ export interface ScaffolderService { createdBy?: string; limit?: number; offset?: number; + status?: ScaffolderTaskStatus; }, options: ScaffolderServiceRequestOptions, ): Promise<{ diff --git a/plugins/scaffolder-node/report.api.md b/plugins/scaffolder-node/report.api.md index 54282f370d..b27c8c8cfe 100644 --- a/plugins/scaffolder-node/report.api.md +++ b/plugins/scaffolder-node/report.api.md @@ -391,6 +391,7 @@ export interface ScaffolderService { createdBy?: string; limit?: number; offset?: number; + status?: ScaffolderTaskStatus; }, options: ScaffolderServiceRequestOptions, ): Promise<{ From f5fb2829f7f6c4fabf8f2a63e86a5d7cbc1425a5 Mon Sep 17 00:00:00 2001 From: John Collier Date: Wed, 4 Mar 2026 23:00:02 -0500 Subject: [PATCH 005/434] address review feedback Signed-off-by: John Collier --- .../actions/listScaffolderTasksAction.test.ts | 47 ++++++++++++++++++- .../src/actions/listScaffolderTasksAction.ts | 30 ++++++++---- .../scaffolder-node/report-testUtils.api.md | 2 +- plugins/scaffolder-node/report.api.md | 2 +- .../scaffolder-node/src/scaffolderService.ts | 8 ++-- 5 files changed, 74 insertions(+), 15 deletions(-) diff --git a/plugins/scaffolder-backend/src/actions/listScaffolderTasksAction.test.ts b/plugins/scaffolder-backend/src/actions/listScaffolderTasksAction.test.ts index bad0b366b6..20eac54cf8 100644 --- a/plugins/scaffolder-backend/src/actions/listScaffolderTasksAction.test.ts +++ b/plugins/scaffolder-backend/src/actions/listScaffolderTasksAction.test.ts @@ -199,7 +199,7 @@ describe('createListScaffolderTasksAction', () => { ); }); - it('should filter tasks by status when status is provided', async () => { + 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(); @@ -244,6 +244,51 @@ describe('createListScaffolderTasksAction', () => { }); }); + 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(); diff --git a/plugins/scaffolder-backend/src/actions/listScaffolderTasksAction.ts b/plugins/scaffolder-backend/src/actions/listScaffolderTasksAction.ts index 2b05b8aee1..3f3a0eed04 100644 --- a/plugins/scaffolder-backend/src/actions/listScaffolderTasksAction.ts +++ b/plugins/scaffolder-backend/src/actions/listScaffolderTasksAction.ts @@ -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 => @@ -66,16 +66,28 @@ Pagination is supported via limit and offset. .describe('The offset to start from for pagination') .optional(), status: z - .enum([ - 'open', - 'processing', - 'completed', - 'failed', - 'cancelled', - 'skipped', + .union([ + z.enum([ + 'open', + 'processing', + 'completed', + 'failed', + 'cancelled', + 'skipped', + ]), + z.array( + z.enum([ + 'open', + 'processing', + 'completed', + 'failed', + 'cancelled', + 'skipped', + ]), + ), ]) .optional() - .describe('Filter tasks by status'), + .describe('Filter tasks by status, or an array of statuses'), }), output: z => z diff --git a/plugins/scaffolder-node/report-testUtils.api.md b/plugins/scaffolder-node/report-testUtils.api.md index 51bc0d0d5e..0dcbd460a2 100644 --- a/plugins/scaffolder-node/report-testUtils.api.md +++ b/plugins/scaffolder-node/report-testUtils.api.md @@ -80,7 +80,7 @@ export interface ScaffolderService { createdBy?: string; limit?: number; offset?: number; - status?: ScaffolderTaskStatus; + status?: ScaffolderTaskStatus | ScaffolderTaskStatus[]; }, options: ScaffolderServiceRequestOptions, ): Promise<{ diff --git a/plugins/scaffolder-node/report.api.md b/plugins/scaffolder-node/report.api.md index b27c8c8cfe..95d821a218 100644 --- a/plugins/scaffolder-node/report.api.md +++ b/plugins/scaffolder-node/report.api.md @@ -391,7 +391,7 @@ export interface ScaffolderService { createdBy?: string; limit?: number; offset?: number; - status?: ScaffolderTaskStatus; + status?: ScaffolderTaskStatus | ScaffolderTaskStatus[]; }, options: ScaffolderServiceRequestOptions, ): Promise<{ diff --git a/plugins/scaffolder-node/src/scaffolderService.ts b/plugins/scaffolder-node/src/scaffolderService.ts index 74d301b718..cb3e8dd675 100644 --- a/plugins/scaffolder-node/src/scaffolderService.ts +++ b/plugins/scaffolder-node/src/scaffolderService.ts @@ -83,7 +83,7 @@ export interface ScaffolderService { createdBy?: string; limit?: number; offset?: number; - status?: ScaffolderTaskStatus; + status?: ScaffolderTaskStatus | ScaffolderTaskStatus[]; }, options: ScaffolderServiceRequestOptions, ): Promise<{ items: ScaffolderTask[]; totalItems: number }>; @@ -186,7 +186,7 @@ class DefaultScaffolderService implements ScaffolderService { createdBy?: string; limit?: number; offset?: number; - status?: ScaffolderTaskStatus; + status?: ScaffolderTaskStatus | ScaffolderTaskStatus[]; }, options: ScaffolderServiceRequestOptions, ): Promise<{ items: ScaffolderTask[]; totalItems: number }> { @@ -204,7 +204,9 @@ class DefaultScaffolderService implements ScaffolderService { params.set('offset', String(request.offset)); } if (request.status !== undefined) { - params.set('status', request.status); + for (const s of [request.status].flat()) { + params.append('status', s); + } } const query = params.toString(); From 4445d88cff3ce374110c693553e2ca3aaef4ab29 Mon Sep 17 00:00:00 2001 From: John Collier Date: Thu, 5 Mar 2026 10:54:33 -0500 Subject: [PATCH 006/434] address pr review feedback Signed-off-by: John Collier --- .changeset/fifty-clubs-play.md | 2 +- .../src/actions/listScaffolderTasksAction.ts | 37 +++++------- .../src/scaffolderService.test.ts | 57 +++++++++++++++++++ 3 files changed, 72 insertions(+), 24 deletions(-) diff --git a/.changeset/fifty-clubs-play.md b/.changeset/fifty-clubs-play.md index 561e24a130..33d4378e69 100644 --- a/.changeset/fifty-clubs-play.md +++ b/.changeset/fifty-clubs-play.md @@ -2,4 +2,4 @@ '@backstage/plugin-scaffolder-backend': minor --- -Updated the `list-scaffolder-tasks` action to support the new "status" filter paramter, allowing the action to return tasks matching a specific status. +Updated the `list-scaffolder-tasks` action to support the new "status" filter parameter, allowing the action to return tasks matching a specific status. diff --git a/plugins/scaffolder-backend/src/actions/listScaffolderTasksAction.ts b/plugins/scaffolder-backend/src/actions/listScaffolderTasksAction.ts index 3f3a0eed04..b3374a0946 100644 --- a/plugins/scaffolder-backend/src/actions/listScaffolderTasksAction.ts +++ b/plugins/scaffolder-backend/src/actions/listScaffolderTasksAction.ts @@ -65,29 +65,20 @@ Filtering by one or multiple statuses is supported. Pagination is supported via .min(0) .describe('The offset to start from for pagination') .optional(), - status: z - .union([ - z.enum([ - 'open', - 'processing', - 'completed', - 'failed', - 'cancelled', - 'skipped', - ]), - z.array( - z.enum([ - 'open', - 'processing', - 'completed', - 'failed', - 'cancelled', - 'skipped', - ]), - ), - ]) - .optional() - .describe('Filter tasks by status, or an array of statuses'), + 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 diff --git a/plugins/scaffolder-node/src/scaffolderService.test.ts b/plugins/scaffolder-node/src/scaffolderService.test.ts index de0870751f..f9b0d85f32 100644 --- a/plugins/scaffolder-node/src/scaffolderService.test.ts +++ b/plugins/scaffolder-node/src/scaffolderService.test.ts @@ -202,4 +202,61 @@ describe('scaffolderServiceRef', () => { expect(result).toEqual({ items: [], totalItems: 0 }); }); + + it('should serialize a single status as a repeated query param for listTasks', async () => { + expect.assertions(1); + + server.use( + rest.get('*/api/scaffolder/v2/tasks', (req, res, ctx) => { + expect(req.url.searchParams.getAll('status')).toEqual(['completed']); + return res(ctx.json({ tasks: [], totalTasks: 0 })); + }), + ); + + const tester = ServiceFactoryTester.from( + createServiceFactory({ + service: createServiceRef({ id: 'unused-dummy' }), + deps: {}, + factory() {}, + }), + { dependencies: [mockServices.discovery.factory()] }, + ); + + const scaffolder = await tester.getService(scaffolderServiceRef); + + await scaffolder.listTasks( + { status: 'completed' }, + { credentials: mockCredentials.user() }, + ); + }); + + it('should serialize multiple statuses as repeated query params for listTasks', async () => { + expect.assertions(1); + + server.use( + rest.get('*/api/scaffolder/v2/tasks', (req, res, ctx) => { + expect(req.url.searchParams.getAll('status')).toEqual([ + 'completed', + 'failed', + ]); + return res(ctx.json({ tasks: [], totalTasks: 0 })); + }), + ); + + const tester = ServiceFactoryTester.from( + createServiceFactory({ + service: createServiceRef({ id: 'unused-dummy' }), + deps: {}, + factory() {}, + }), + { dependencies: [mockServices.discovery.factory()] }, + ); + + const scaffolder = await tester.getService(scaffolderServiceRef); + + await scaffolder.listTasks( + { status: ['completed', 'failed'] }, + { credentials: mockCredentials.user() }, + ); + }); }); From 08c922e55715196ffa1b2cbe4484e60cf9c70df6 Mon Sep 17 00:00:00 2001 From: Aditya Kumar Date: Tue, 10 Mar 2026 09:53:18 +0530 Subject: [PATCH 007/434] migrate ConfigContent component to Backstage UI Signed-off-by: Aditya Kumar --- .changeset/purple-insects-cross.md | 12 ++++++ plugins/devtools/package.json | 1 + .../Content/ConfigContent/ConfigContent.tsx | 42 ++++++------------- yarn.lock | 1 + 4 files changed, 26 insertions(+), 30 deletions(-) create mode 100644 .changeset/purple-insects-cross.md diff --git a/.changeset/purple-insects-cross.md b/.changeset/purple-insects-cross.md new file mode 100644 index 0000000000..b0d08b62a3 --- /dev/null +++ b/.changeset/purple-insects-cross.md @@ -0,0 +1,12 @@ +--- +'@backstage/plugin-devtools': patch +--- + +Migrated `ConfigContent` component from Material UI to Backstage UI (BUI). + +Replaced MUI components with their BUI equivalents: + +- `Box`, `Paper` → `Box` with `bg` and `p` props +- `Typography` → `Text` with `as="p"` +- `Alert` → `Alert` with `status` and `title` props +- Removed `makeStyles`/`createStyles` in favor of BUI spacing props diff --git a/plugins/devtools/package.json b/plugins/devtools/package.json index 582c2fd4e5..133aa2dc03 100644 --- a/plugins/devtools/package.json +++ b/plugins/devtools/package.json @@ -61,6 +61,7 @@ "@backstage/plugin-devtools-common": "workspace:^", "@backstage/plugin-devtools-react": "workspace:^", "@backstage/plugin-permission-react": "workspace:^", + "@backstage/ui": "workspace:^", "@material-ui/core": "^4.9.13", "@material-ui/icons": "^4.9.1", "@material-ui/lab": "^4.0.0-alpha.57", diff --git a/plugins/devtools/src/components/Content/ConfigContent/ConfigContent.tsx b/plugins/devtools/src/components/Content/ConfigContent/ConfigContent.tsx index 0130d9fda2..561cc1ae53 100644 --- a/plugins/devtools/src/components/Content/ConfigContent/ConfigContent.tsx +++ b/plugins/devtools/src/components/Content/ConfigContent/ConfigContent.tsx @@ -15,42 +15,25 @@ */ import { Progress, WarningPanel } from '@backstage/core-components'; -import Box from '@material-ui/core/Box'; -import Paper from '@material-ui/core/Paper'; -import Typography from '@material-ui/core/Typography'; -import { - createStyles, - makeStyles, - Theme, - useTheme, -} from '@material-ui/core/styles'; -import Alert from '@material-ui/lab/Alert'; +import { Alert, Box, Text } from '@backstage/ui'; +import { useTheme } from '@material-ui/core/styles'; import ReactJson from 'react-json-view'; import { useConfig } from '../../../hooks'; import { ConfigError } from '@backstage/plugin-devtools-common'; -const useStyles = makeStyles((theme: Theme) => - createStyles({ - warningStyle: { - paddingBottom: theme.spacing(2), - }, - paperStyle: { - padding: theme.spacing(2), - }, - }), -); - export const WarningContent = ({ error }: { error: ConfigError }) => { if (!error.messages) { - return {error.message}; + return {error.message}; } const messages = error.messages as string[]; return ( - {messages.map(message => ( - {message} + {messages.map((message, index) => ( + + {message} + ))} ); @@ -58,37 +41,36 @@ export const WarningContent = ({ error }: { error: ConfigError }) => { /** @public */ export const ConfigContent = () => { - const classes = useStyles(); const theme = useTheme(); const { configInfo, loading, error } = useConfig(); if (loading) { return ; } else if (error) { - return {error.message}; + return ; } if (!configInfo) { - return Unable to load config data; + return ; } return ( {configInfo && configInfo.error && ( - + )} - + - + ); }; diff --git a/yarn.lock b/yarn.lock index 870ee45827..5dd9df3dcd 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5756,6 +5756,7 @@ __metadata: "@backstage/plugin-devtools-common": "workspace:^" "@backstage/plugin-devtools-react": "workspace:^" "@backstage/plugin-permission-react": "workspace:^" + "@backstage/ui": "workspace:^" "@material-ui/core": "npm:^4.9.13" "@material-ui/icons": "npm:^4.9.1" "@material-ui/lab": "npm:^4.0.0-alpha.57" From 9e88f0ce40be984a760ca962d6cb4637812e1989 Mon Sep 17 00:00:00 2001 From: Aditya Kumar Date: Tue, 10 Mar 2026 10:24:51 +0530 Subject: [PATCH 008/434] made changes suggested by copilot Signed-off-by: Aditya Kumar --- .../src/components/Content/ConfigContent/ConfigContent.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/plugins/devtools/src/components/Content/ConfigContent/ConfigContent.tsx b/plugins/devtools/src/components/Content/ConfigContent/ConfigContent.tsx index 561cc1ae53..868dab2353 100644 --- a/plugins/devtools/src/components/Content/ConfigContent/ConfigContent.tsx +++ b/plugins/devtools/src/components/Content/ConfigContent/ConfigContent.tsx @@ -26,12 +26,12 @@ export const WarningContent = ({ error }: { error: ConfigError }) => { return {error.message}; } - const messages = error.messages as string[]; + const messages = error.messages; return ( - {messages.map((message, index) => ( - + {messages.map(message => ( + {message} ))} From 7e841e6c3936364f409791a8fc55e6a00e214d77 Mon Sep 17 00:00:00 2001 From: Oscar Reyes Date: Tue, 10 Mar 2026 11:29:22 -0600 Subject: [PATCH 009/434] chore(plugins): Adding testkube plugin data Signed-off-by: Oscar Reyes --- microsite/data/plugins/testkube.yaml | 11 +++++++++++ microsite/static/img/testkube-logo.svg | 1 + 2 files changed, 12 insertions(+) create mode 100644 microsite/data/plugins/testkube.yaml create mode 100644 microsite/static/img/testkube-logo.svg diff --git a/microsite/data/plugins/testkube.yaml b/microsite/data/plugins/testkube.yaml new file mode 100644 index 0000000000..e47fb7b015 --- /dev/null +++ b/microsite/data/plugins/testkube.yaml @@ -0,0 +1,11 @@ +--- +title: Testkube +author: Testkube +authorUrl: https://testkube.io +category: Test Orchestration +description: Catalog, Execute, Troubleshoot, & Report on automated testing from one centralized platform. +documentation: https://github.com/kubeshop/testkube-backstage-plugin +iconUrl: /img/testkube-logo.svg +npmPackageName: '@testkube/backstage-plugin' +addedDate: '2026-03-10' +status: active diff --git a/microsite/static/img/testkube-logo.svg b/microsite/static/img/testkube-logo.svg new file mode 100644 index 0000000000..7416317ed9 --- /dev/null +++ b/microsite/static/img/testkube-logo.svg @@ -0,0 +1 @@ + \ No newline at end of file From 4987665d31246602808a406c24ffbf0696168f06 Mon Sep 17 00:00:00 2001 From: letthem Date: Wed, 11 Mar 2026 13:29:41 +0900 Subject: [PATCH 010/434] chore: trigger Signed-off-by: letthem From 9df38f2e53c2594cad66ee3351f51d4ffd5b2526 Mon Sep 17 00:00:00 2001 From: chanchalkhatri19 Date: Thu, 12 Mar 2026 06:03:53 +0000 Subject: [PATCH 011/434] docs: add accountId option to AWS S3 discovery config examples Signed-off-by: chanchalkhatri19 --- docs/integrations/aws-s3/discovery.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/integrations/aws-s3/discovery.md b/docs/integrations/aws-s3/discovery.md index 74e2fde6a5..c18f119444 100644 --- a/docs/integrations/aws-s3/discovery.md +++ b/docs/integrations/aws-s3/discovery.md @@ -31,6 +31,7 @@ catalog: bucketName: sample-bucket prefix: prefix/ # optional region: us-east-2 # optional, uses the default region otherwise + accountId: '123456789012' # optional, uses the main account otherwise schedule: # same options as in SchedulerServiceTaskScheduleDefinition # supports cron, ISO duration, "human duration" as used in code frequency: { minutes: 30 } @@ -51,6 +52,7 @@ catalog: bucketName: sample-bucket prefix: prefix/ # optional region: us-east-2 # optional, uses the default region otherwise + accountId: '123456789012' # optional, uses the main account otherwise schedule: # same options as in SchedulerServiceTaskScheduleDefinition # supports cron, ISO duration, "human duration" as used in code frequency: { minutes: 30 } From c33e5f583ead6c96c61196571669a2a263b5d0cd Mon Sep 17 00:00:00 2001 From: Josh Granberry Date: Fri, 20 Mar 2026 14:00:42 -0500 Subject: [PATCH 012/434] docs: correct syntax for configuring RUM instrumentation Signed-off-by: Josh Granberry --- docs/integrations/datadog-rum/installation.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/integrations/datadog-rum/installation.md b/docs/integrations/datadog-rum/installation.md index 5b4fe8bd40..4b229dec85 100644 --- a/docs/integrations/datadog-rum/installation.md +++ b/docs/integrations/datadog-rum/installation.md @@ -62,9 +62,9 @@ In case after a proper configuration, the events still are not being captured: C service: 'backstage', env: '<%= config.getString("app.datadogRum.env") %>', sampleRate: - '<%= config.getOptionalNumber("app.datadogRum.sessionSampleRate") || 100 %>', + <%= config.getOptionalNumber("app.datadogRum.sessionSampleRate") || 100 %>, sessionReplaySampleRate: - '<%= config.getOptionalNumber("app.datadogRum.sessionReplaySampleRate") || 0 %>', + <%= config.getOptionalNumber("app.datadogRum.sessionReplaySampleRate") || 0 %>, trackInteractions: true, }); }); From f9c57344d73303bd350220cd6c7d977e4294faf7 Mon Sep 17 00:00:00 2001 From: aramissennyeydd Date: Tue, 24 Mar 2026 16:33:57 +0100 Subject: [PATCH 013/434] docs: deployment golden path guide Signed-off-by: aramissennyeydd --- docs/golden-path/deployment/001-docker.md | 110 +++++++++++ docs/golden-path/deployment/002-database.md | 103 ++++++++++ .../deployment/003-authentication.md | 90 +++++++++ docs/golden-path/deployment/004-deploying.md | 186 ++++++++++++++++++ .../deployment/005-config-first.md | 92 +++++++++ docs/golden-path/deployment/006-monitoring.md | 83 ++++++++ docs/golden-path/deployment/007-scaling.md | 96 +++++++++ docs/golden-path/deployment/index.md | 38 ++++ 8 files changed, 798 insertions(+) create mode 100644 docs/golden-path/deployment/001-docker.md create mode 100644 docs/golden-path/deployment/002-database.md create mode 100644 docs/golden-path/deployment/003-authentication.md create mode 100644 docs/golden-path/deployment/004-deploying.md create mode 100644 docs/golden-path/deployment/005-config-first.md create mode 100644 docs/golden-path/deployment/006-monitoring.md create mode 100644 docs/golden-path/deployment/007-scaling.md diff --git a/docs/golden-path/deployment/001-docker.md b/docs/golden-path/deployment/001-docker.md new file mode 100644 index 0000000000..80549e0021 --- /dev/null +++ b/docs/golden-path/deployment/001-docker.md @@ -0,0 +1,110 @@ +--- +id: docker +sidebar_label: 001 - Building the Docker image +title: Building the Docker image +description: How to build your Backstage app into a deployable Docker image +--- + +Audience: Developers and Admins + +## Summary + +Every production deployment of Backstage starts with a Docker image. The image +bundles both the frontend and backend into a single artifact that you can deploy +anywhere containers run. + +By the end of this page, you will have a Docker image ready to push to a +container registry. + +## What is in the Docker image? + +When you created your app with `@backstage/create-app`, a `Dockerfile` was +generated at `packages/backend/Dockerfile`. The build process layers both the +frontend and backend into a single image: + +1. The **backend** is compiled and bundled into `packages/backend/dist/`. +2. The **frontend** is built and served by the + `@backstage/plugin-app-backend` plugin, which is included in the backend + by default. +3. Production dependencies are installed, and the result is packaged into a + slim Node.js image. + +The image runs as a non-root `node` user and sets `NODE_ENV=production`. + +## Building the image + +The recommended approach is a **host build**, where compilation happens on +your machine (or CI runner) and Docker only packages the result. This is faster +and produces better caching behavior. + +From the root of your repository: + +```shell +yarn install --immutable +yarn tsc +yarn build:backend +``` + +Then build the Docker image: + +```shell +docker image build . -f packages/backend/Dockerfile --tag backstage +``` + +To verify it works locally: + +```shell +docker run -it -p 7007:7007 backstage +``` + +You should see logs in your terminal and be able to open `http://localhost:7007` +in your browser. + +## Running the build in CI + +While you can run this build locally, you will typically run it as part of your +CI pipeline. A GitHub Actions workflow might look like this: + +```yaml +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 24 + - run: yarn install --immutable + - run: yarn tsc + - run: yarn build:backend + - run: docker image build . -f packages/backend/Dockerfile --tag backstage +``` + +After building, push the image to your container registry (ECR, GCR, Docker +Hub, etc.) so that your deployment platform can pull it. + +:::tip Troubleshooting + +If you run into build issues, two Docker flags can help: + +- `--progress=plain` shows verbose output instead of folded log sections. +- `--no-cache` rebuilds all layers from scratch. + +```shell +docker image build . -f packages/backend/Dockerfile --tag backstage --progress=plain --no-cache +``` + +::: + +## Further reading + +For a full multi-stage Docker build (where everything happens inside Docker) +or for deploying the frontend separately, see the +[Building a Docker image](../../deployment/docker.md) reference documentation. + +## Next steps + +Before deploying, you need to set up two production dependencies: a database and +an authentication provider. + +- [Setting up a production database](./002-database.md) diff --git a/docs/golden-path/deployment/002-database.md b/docs/golden-path/deployment/002-database.md new file mode 100644 index 0000000000..db6fd76da1 --- /dev/null +++ b/docs/golden-path/deployment/002-database.md @@ -0,0 +1,103 @@ +--- +id: database +sidebar_label: 002 - Database +title: Setting up a production database +description: How to configure PostgreSQL for your Backstage production deployment +--- + +Audience: Admins + +## Summary + +During local development, Backstage uses SQLite as a fast in-memory database. +SQLite does not persist data across restarts and is not designed for +multi-instance deployments, so you need a dedicated database for production. + +By the end of this page, you will have PostgreSQL configured as your Backstage +database. + +## Why PostgreSQL? + +PostgreSQL is the recommended production database for Backstage. It handles +concurrent connections well, supports the query patterns that Backstage plugins +use, and is available as a managed service from every major cloud provider. + +Some options for running PostgreSQL: + +- **Managed services**: Amazon RDS, Google Cloud SQL, Azure Database for + PostgreSQL, or similar offerings. +- **Self-hosted**: Running PostgreSQL in a container or on a dedicated server. +- **In Kubernetes**: Deploying PostgreSQL alongside Backstage (useful for + getting started, but managed services are preferred for production). + +## Configuring Backstage to use PostgreSQL + +Open your `app-config.production.yaml` and add the database configuration: + +```yaml title="app-config.production.yaml" +backend: + database: + client: pg + connection: + host: ${POSTGRES_HOST} + port: ${POSTGRES_PORT} + user: ${POSTGRES_USER} + password: ${POSTGRES_PASSWORD} +``` + +The `${...}` syntax references environment variables, which keeps secrets out +of your configuration files. Set these variables in your deployment environment. + +:::caution + +Avoid hardcoding database credentials in configuration files. Use environment +variables or a secrets manager provided by your deployment platform. + +::: + +### Optional: SSL connections + +If your database provider requires SSL (most managed services do), add the +SSL configuration: + +```yaml title="app-config.production.yaml" +backend: + database: + client: pg + connection: + host: ${POSTGRES_HOST} + port: ${POSTGRES_PORT} + user: ${POSTGRES_USER} + password: ${POSTGRES_PASSWORD} + ssl: + require: true + rejectUnauthorized: true +``` + +If your provider uses a custom certificate authority, you can reference the +CA certificate file: + +```yaml +ssl: + ca: + $file: /path/to/ca/server.crt +``` + +## How Backstage uses the database + +Each plugin gets its own isolated database schema. Migrations run automatically +when the backend starts, so you do not need to run any manual migration steps. +The database coordinates state and work distribution between instances, which +is what makes horizontal scaling possible later on. + +## Further reading + +For step-by-step PostgreSQL installation instructions and cloud-specific +setup guides, see the [Database configuration guide](../../getting-started/config/database.md). + +## Next steps + +With the database in place, the next step is to replace the guest login with +a real authentication provider. + +- [Configuring authentication](./003-authentication.md) diff --git a/docs/golden-path/deployment/003-authentication.md b/docs/golden-path/deployment/003-authentication.md new file mode 100644 index 0000000000..110e2e914a --- /dev/null +++ b/docs/golden-path/deployment/003-authentication.md @@ -0,0 +1,90 @@ +--- +id: authentication +sidebar_label: 003 - Authentication +title: Configuring authentication +description: How to set up a production authentication provider for Backstage +--- + +Audience: Developers and Admins + +## Summary + +By default, Backstage uses a guest authentication provider that lets anyone +log in without credentials. This is convenient for local development but +creates a security risk when deployed to production. Before deploying, you +should configure a real authentication provider. + +By the end of this page, you will understand what needs to change and where +to find the setup instructions for your chosen provider. + +## Why replace guest authentication? + +The guest provider is explicitly not intended for containerized or production +environments. With guest auth enabled, any user who can reach your Backstage +instance shares the same identity and has the same level of access. Replacing +it is one of the most important steps before going to production. + +:::danger + +Deploying Backstage with the guest authentication provider exposes your +instance to unauthorized access. Configure a real provider before deploying. + +::: + +## Choosing an authentication provider + +Backstage supports a wide range of authentication providers. GitHub is a +common choice since most developers already have accounts, but you should +pick whatever your organization already uses for single sign-on. + +Some popular options: + +| Provider | Good fit when... | +| :------------------- | :------------------------------------------------ | +| GitHub | Your team uses GitHub and you want a quick setup. | +| Microsoft / Azure AD | Your company uses Microsoft Entra ID (Azure AD). | +| Google | Your company uses Google Workspace. | +| Okta | You use Okta as your identity provider. | +| OIDC | You have a generic OpenID Connect provider. | +| OAuth2 Proxy | You already use an authenticating reverse proxy. | + +The full list of providers and their configuration is available in the +[Authentication documentation](../../auth/index.md). + +## What the setup involves + +Regardless of which provider you choose, the setup follows the same pattern: + +1. **Create an OAuth application** (or equivalent) with your identity + provider. You will get a client ID and client secret. +2. **Add the provider configuration** to `app-config.yaml` with the + credentials, typically using environment variables for secrets. +3. **Install the backend auth module** for your chosen provider in + `packages/backend`. +4. **Configure a sign-in resolver** that maps the authenticated user identity + to a Backstage catalog user entity. +5. **Update the frontend** to show the correct sign-in page. + +The [Authentication getting started guide](../../getting-started/config/authentication.md) +walks through this process step by step using GitHub as the example provider. + +## Production configuration + +Once authentication is configured, make sure that the guest provider is +disabled in your production config: + +```yaml title="app-config.production.yaml" +auth: + providers: + guest: null +``` + +This ensures that even if the guest provider is configured in your base +`app-config.yaml` for local development, it is explicitly disabled in +production. + +## Next steps + +With both a database and authentication configured, you are ready to deploy. + +- [Deploying to production](./004-deploying.md) diff --git a/docs/golden-path/deployment/004-deploying.md b/docs/golden-path/deployment/004-deploying.md new file mode 100644 index 0000000000..eb900bbcd5 --- /dev/null +++ b/docs/golden-path/deployment/004-deploying.md @@ -0,0 +1,186 @@ +--- +id: deploying +sidebar_label: 004 - Deploying +title: Deploying to production +description: How to deploy your Backstage instance to Kubernetes and other platforms +--- + +Audience: Admins + +## Summary + +You have a Docker image, a database, and authentication configured. Now it is +time to deploy. The best way to deploy Backstage is _the same way_ you deploy +other software at your organization. This page covers deploying to Kubernetes +as a concrete example and points to resources for other platforms. + +## Deploying to Kubernetes + +Kubernetes is the most common deployment target for Backstage. The deployment +involves a few Kubernetes objects: a namespace, secrets, a deployment, and a +service. + +### 1. Create a namespace + +```yaml +# kubernetes/namespace.yaml +apiVersion: v1 +kind: Namespace +metadata: + name: backstage +``` + +```shell +kubectl apply -f kubernetes/namespace.yaml +``` + +### 2. Create secrets + +Store your database credentials and any other secrets (API tokens, auth +client secrets) as Kubernetes Secrets: + +```yaml +# kubernetes/backstage-secrets.yaml +apiVersion: v1 +kind: Secret +metadata: + name: backstage-secrets + namespace: backstage +type: Opaque +data: + POSTGRES_USER: + POSTGRES_PASSWORD: +``` + +:::note + +Kubernetes secrets are base64-encoded but not encrypted. Enable +[Encryption at Rest](https://kubernetes.io/docs/tasks/administer-cluster/encrypt-data/) +for your cluster and consider tools like +[SealedSecrets](https://github.com/bitnami-labs/sealed-secrets) for storing +secrets in Git. + +::: + +```shell +kubectl apply -f kubernetes/backstage-secrets.yaml +``` + +### 3. Create the deployment + +```yaml +# kubernetes/backstage.yaml +apiVersion: apps/v1 +kind: Deployment +metadata: + name: backstage + namespace: backstage +spec: + replicas: 1 + selector: + matchLabels: + app: backstage + template: + metadata: + labels: + app: backstage + spec: + containers: + - name: backstage + image: /backstage: + imagePullPolicy: IfNotPresent + ports: + - name: http + containerPort: 7007 + envFrom: + - secretRef: + name: backstage-secrets + readinessProbe: + httpGet: + port: 7007 + path: /.backstage/health/v1/readiness + livenessProbe: + httpGet: + port: 7007 + path: /.backstage/health/v1/liveness +``` + +Replace `/backstage:` with the full image URL from your +container registry. + +```shell +kubectl apply -f kubernetes/backstage.yaml +``` + +### 4. Create a service + +```yaml +# kubernetes/backstage-service.yaml +apiVersion: v1 +kind: Service +metadata: + name: backstage + namespace: backstage +spec: + selector: + app: backstage + ports: + - name: http + port: 80 + targetPort: http +``` + +```shell +kubectl apply -f kubernetes/backstage-service.yaml +``` + +### 5. Expose the service + +The Service is not accessible outside the cluster by default. Use a Kubernetes +[Ingress](https://kubernetes.io/docs/concepts/services-networking/ingress/) +or an +[external load balancer](https://kubernetes.io/docs/tasks/access-application-cluster/create-external-load-balancer/) +to expose Backstage to your users. + +Make sure the `app.baseUrl` and `backend.baseUrl` values in your +`app-config.production.yaml` match the URL where users will access Backstage: + +```yaml title="app-config.production.yaml" +app: + baseUrl: https://backstage.example.com +backend: + baseUrl: https://backstage.example.com + listen: + port: 7007 +``` + +## Other deployment platforms + +Backstage is a standard Node.js application running in a Docker container, so +it works on any platform that supports containers: + +- **Amazon ECS**: Create a task definition referencing your Docker image, set + up an ECS service, and place it behind an Application Load Balancer. +- **Google Cloud Run**: Deploy the image as a Cloud Run service with the + appropriate environment variables. +- **Azure Container Apps**: Deploy the image and configure ingress through + the Azure portal or CLI. +- **Docker Compose**: Suitable for smaller installations. Run Backstage and + PostgreSQL together using a `docker-compose.yaml`. + +Community-contributed deployment guides are available in the +[contrib/docs/tutorials](https://github.com/backstage/backstage/blob/master/contrib/docs/tutorials/) +folder of the Backstage repository. + +## Further reading + +For a detailed guide including setting up PostgreSQL in Kubernetes, see +the [Deploying with Kubernetes](../../deployment/k8s.md) reference +documentation. + +## Next steps + +Your Backstage instance is deployed. Next, let's look at how to manage +configuration effectively. + +- [Config-first development](./005-config-first.md) diff --git a/docs/golden-path/deployment/005-config-first.md b/docs/golden-path/deployment/005-config-first.md new file mode 100644 index 0000000000..1725c3f610 --- /dev/null +++ b/docs/golden-path/deployment/005-config-first.md @@ -0,0 +1,92 @@ +--- +id: config-first +sidebar_label: 005 - Configuration management +title: Config-first development +description: Managing Backstage configuration across environments +--- + +Audience: Developers and Admins + +## Summary + +One of the things that makes Backstage easier to operate over time is treating +configuration as the primary way to control application behavior. Instead of +writing custom code for every change, many behaviors can be toggled, adjusted, +or extended through configuration files. + +By the end of this page, you will understand how Backstage configuration +layering works and how to manage it across environments. + +## How configuration layering works + +Backstage loads configuration from multiple `app-config*.yaml` files and +merges them together. Files loaded later override values from earlier files. +The Docker image built in the [first step](./001-docker.md) starts with this +command: + +``` +node packages/backend --config app-config.yaml --config app-config.production.yaml +``` + +This means `app-config.production.yaml` overrides any values set in +`app-config.yaml`. You can use this pattern to keep your base config for +local development and override only what changes in production. + +### Common configuration split + +| File | Purpose | +| :--------------------------- | :------------------------------------------------- | +| `app-config.yaml` | Base configuration shared across all environments. | +| `app-config.local.yaml` | Local overrides, not committed to source control. | +| `app-config.production.yaml` | Production-specific overrides. | + +## Environment variables in config + +Use the `${VAR_NAME}` syntax to reference environment variables. This is +the recommended approach for secrets and values that differ between +environments: + +```yaml title="app-config.production.yaml" +backend: + database: + client: pg + connection: + host: ${POSTGRES_HOST} + port: ${POSTGRES_PORT} + user: ${POSTGRES_USER} + password: ${POSTGRES_PASSWORD} +``` + +This keeps secrets out of your repository and lets the same Docker image be +used across environments by changing the variables at deploy time. + +## What to configure vs. what to code + +A good rule of thumb: if a change affects _where_ something connects, _how_ +it authenticates, or _which_ features are enabled, it belongs in configuration. +If it changes _what_ a feature does, it belongs in code. + +Examples of config-driven behavior: + +- Database connection details. +- Authentication provider selection and credentials. +- Catalog locations and entity providers. +- Integration tokens (GitHub, GitLab, etc.). +- TechDocs storage backend (local, S3, GCS, Azure). +- Proxy endpoints for external services. + +Making configuration your primary lever for environment differences simplifies +your CI/CD pipeline. You build one Docker image and deploy it everywhere, +varying only the config files and environment variables. + +## Further reading + +For the full configuration reference, see the +[Configuration documentation](../../conf/index.md). + +## Next steps + +With your deployment running and configuration managed, you should set up +monitoring to keep it healthy. + +- [Monitoring your deployment](./006-monitoring.md) diff --git a/docs/golden-path/deployment/006-monitoring.md b/docs/golden-path/deployment/006-monitoring.md new file mode 100644 index 0000000000..e082a74788 --- /dev/null +++ b/docs/golden-path/deployment/006-monitoring.md @@ -0,0 +1,83 @@ +--- +id: monitoring +sidebar_label: 006 - Monitoring +title: Monitoring your deployment +description: Setting up OpenTelemetry and frontend analytics for your Backstage deployment +--- + +Audience: Admins + +## Summary + +A production Backstage deployment needs monitoring to track health, diagnose +issues, and understand usage patterns. Backstage provides built-in support +for OpenTelemetry on the backend and an Analytics API on the frontend. + +By the end of this page, you will know how to set up both. + +## Backend monitoring with OpenTelemetry + +Backstage uses [OpenTelemetry](https://opentelemetry.io/) to report metrics +and traces. The setup involves installing a few OpenTelemetry packages, +creating an instrumentation file, and loading it before the backend starts. + +Follow the [Setup OpenTelemetry tutorial](../../tutorials/setup-opentelemetry.md) +for step-by-step instructions on installing dependencies, configuring +exporters (Prometheus for metrics, OTLP for traces), and wiring everything +into your backend. + +### Key metrics to monitor + +Backstage plugins emit metrics that give you insight into system health: + +- `catalog_entities_count` - Total number of entities in the catalog. +- `catalog.processed.entities.count` - Number of entities processed. +- `catalog.processing.duration` - Time spent processing entities. +- `scaffolder.task.count` - Number of scaffolder tasks run. +- `scaffolder.task.duration` - Time taken by scaffolder tasks. + +These metrics help you set up alerts for things like processing backlogs or +unusually slow scaffolder runs. + +### Health checks + +Backstage provides built-in health check endpoints that you can use for +liveness and readiness probes in Kubernetes or other orchestrators: + +- `/.backstage/health/v1/readiness` - Returns healthy when the backend is + ready to serve traffic. +- `/.backstage/health/v1/liveness` - Returns healthy when the backend + process is alive. + +## Frontend analytics + +Backstage provides an Analytics API for tracking user behavior on the +frontend. This is useful for understanding which plugins get the most use +and measuring the return on your Backstage investment. + +Several analytics tools are supported through community plugins: + +- Google Analytics 4 +- New Relic Browser +- Matomo + +See the [Analytics documentation](../../frontend-system/building-plugins/08-analytics.md) +for setup instructions. + +For frontend error reporting, consider integrating with a service like +Sentry, CloudWatch RUM, or Cloudflare RUM to catch and diagnose client-side +errors. + +## Logging + +The backend emits structured JSON logs on stdout by default. These logs +include fields like `service`, `plugin`, `level`, and `message` that make +them easy to parse with log aggregation tools (Elasticsearch, Datadog, +Splunk, etc.). + +## Next steps + +As more users adopt your Backstage instance, you may need to scale the +deployment. + +- [Scaling your deployment](./007-scaling.md) diff --git a/docs/golden-path/deployment/007-scaling.md b/docs/golden-path/deployment/007-scaling.md new file mode 100644 index 0000000000..d82321df54 --- /dev/null +++ b/docs/golden-path/deployment/007-scaling.md @@ -0,0 +1,96 @@ +--- +id: scaling +sidebar_label: 007 - Scaling +title: Scaling your deployment +description: How to scale Backstage as usage grows +--- + +Audience: Admins + +## Summary + +A single Backstage instance handles many users well, but as your organization +grows and more plugins are added, you may need to scale. This page covers the +strategies available. + +## Horizontal scaling + +The most straightforward approach is to run multiple identical instances of +Backstage behind a load balancer. All instances share the same external +database (and optional cache or search services). The backend plugins +coordinate through the database to share state and distribute work. + +In Kubernetes, this is as simple as increasing the replica count in your +deployment: + +```yaml +spec: + replicas: 3 +``` + +No additional configuration is needed. The database handles coordination +between instances. + +## Splitting the backend + +For larger installations, you can break the backend into multiple services, +each running a different set of plugins. For example, you might run the +catalog and scaffolder as separate deployments so that heavy catalog +processing does not affect scaffolder performance. + +This is a more advanced approach that requires: + +- Separate backend packages, each importing only the plugins they need. +- A custom `DiscoveryService` implementation that routes requests to the + correct backend based on the plugin ID. +- Routing both external (ingress) and internal (backend-to-backend) traffic + appropriately. + +See the +[backend system documentation](../../backend-system/building-backends/01-index.md#split-into-multiple-backends) +for details on how to set this up. + +## Separating the frontend + +By default, the frontend is served from your backend deployment using the +`@backstage/plugin-app-backend` plugin. If you need to reduce load on the +backend or serve the frontend from a CDN for better performance, you can +deploy the frontend separately. + +This involves: + +1. Removing the `@backstage/plugin-app-backend` plugin from the backend. +2. Building the frontend as a static bundle. +3. Serving it from a separate container (for example, NGINX) or a static + hosting provider. + +An example NGINX setup is available in the +[contrib/docker/frontend-with-nginx](https://github.com/backstage/backstage/blob/master/contrib/docker/frontend-with-nginx) +folder. + +:::note + +When serving the frontend separately, configuration is no longer injected by +the backend at runtime. You need to provide the correct configuration at +frontend build time. + +::: + +## When to scale + +Here are some signals that indicate you should consider scaling: + +- API response times are increasing. +- Catalog processing is falling behind (visible in the + `catalog.processing.duration` metric). +- Scaffolder tasks are queuing for longer than expected. +- Users report slow page loads. + +Start with horizontal scaling (more replicas) before considering backend +splitting. It is simpler and handles most growth scenarios. + +## Further reading + +For more details on scaling strategies, see the +[Scaling Backstage Deployments](../../deployment/scaling.md) reference +documentation. diff --git a/docs/golden-path/deployment/index.md b/docs/golden-path/deployment/index.md index e69de29bb2..77df39a986 100644 --- a/docs/golden-path/deployment/index.md +++ b/docs/golden-path/deployment/index.md @@ -0,0 +1,38 @@ +--- +id: index +title: Deploying Backstage to production +description: A guided path for deploying your Backstage app to production +--- + +### Prerequisites + +- You have completed the [create-app golden path](../create-app/index.md) and + have a working Backstage app. +- Your code is pushed to a source control management system (GitHub, GitLab, + etc.). +- You have a general understanding of how your company builds and deploys + software. + +### What should I get out of this guide? + +This guide walks through everything you need to get your Backstage instance +running in a production environment. By the end, you will have: + +- A Docker image containing your Backstage app. +- A production database (PostgreSQL) connected to your deployment. +- A real authentication provider replacing the default guest login. +- A running deployment, whether on Kubernetes, ECS, or another platform. +- Monitoring and observability set up with OpenTelemetry. +- An understanding of how to scale your deployment as usage grows. + +### Structure + +We start with the Docker image since that is the foundation of any deployment. +Then we set up the two critical pre-deploy dependencies: a database and +authentication. After that, we walk through deploying to Kubernetes and discuss +other deployment options. Finally, we cover operational topics like +configuration management, monitoring, and scaling. + +### Next steps + +- [Building the Docker image](./001-docker.md) From a38e03e5d15327ce6e19384579cf3b2ce2ed498e Mon Sep 17 00:00:00 2001 From: ElaineDeMattosSilvaB Date: Thu, 26 Mar 2026 20:58:03 +0100 Subject: [PATCH 014/434] feat: allow empty commits Signed-off-by: ElaineDeMattosSilvaB --- .../actions/gitlabRepoPush.examples.test.ts | 21 +++++++++++++++++++ .../src/actions/gitlabRepoPush.examples.ts | 19 +++++++++++++++++ .../src/actions/gitlabRepoPush.ts | 8 +++++++ 3 files changed, 48 insertions(+) diff --git a/plugins/scaffolder-backend-module-gitlab/src/actions/gitlabRepoPush.examples.test.ts b/plugins/scaffolder-backend-module-gitlab/src/actions/gitlabRepoPush.examples.test.ts index 25b6c89ae4..2a50ee9411 100644 --- a/plugins/scaffolder-backend-module-gitlab/src/actions/gitlabRepoPush.examples.test.ts +++ b/plugins/scaffolder-backend-module-gitlab/src/actions/gitlabRepoPush.examples.test.ts @@ -181,4 +181,25 @@ describe('gitlab:repo:push', () => { ); }); }); + + describe('Push an empty commit to gitlab repository', () => { + it(`Should ${examples[3].description}`, async () => { + const input = yaml.parse(examples[3].example).steps[0].input; + mockDir.setContent({ [workspacePath]: {} }); + const ctx = createMockActionContext({ input, workspacePath }); + await instance.handler(ctx); + + expect(mockGitlabClient.Commits.create).toHaveBeenCalledWith( + 'owner/repo', + 'feature-branch', + 'Initial Commit', + [], + { allowEmpty: true }, + ); + expect(ctx.output).toHaveBeenCalledWith( + 'commitHash', + 'f8a2c9bd4e2915b0792b43235c779e82ddad54af', + ); + }); + }); }); diff --git a/plugins/scaffolder-backend-module-gitlab/src/actions/gitlabRepoPush.examples.ts b/plugins/scaffolder-backend-module-gitlab/src/actions/gitlabRepoPush.examples.ts index 56a59f25bc..e43dbddb42 100644 --- a/plugins/scaffolder-backend-module-gitlab/src/actions/gitlabRepoPush.examples.ts +++ b/plugins/scaffolder-backend-module-gitlab/src/actions/gitlabRepoPush.examples.ts @@ -73,4 +73,23 @@ export const examples: TemplateExample[] = [ ], }), }, + { + description: + 'Push an empty commit to gitlab repository (from GitLab 18.8+ on)', + example: yaml.stringify({ + steps: [ + { + id: 'pushChanges', + action: 'gitlab:repo:push', + name: 'Push empty commit to gitlab repository', + input: { + repoUrl: 'gitlab.com?repo=repo&owner=owner', + commitMessage: 'Initial Commit', + branchName: 'feature-branch', + allowEmpty: true, + }, + }, + ], + }), + }, ]; diff --git a/plugins/scaffolder-backend-module-gitlab/src/actions/gitlabRepoPush.ts b/plugins/scaffolder-backend-module-gitlab/src/actions/gitlabRepoPush.ts index 24f2907041..1bf817aac8 100644 --- a/plugins/scaffolder-backend-module-gitlab/src/actions/gitlabRepoPush.ts +++ b/plugins/scaffolder-backend-module-gitlab/src/actions/gitlabRepoPush.ts @@ -83,6 +83,12 @@ export const createGitlabRepoPushAction = (options: { 'The action to be used for git commit. Defaults to create, but can be set to update or delete', }) .optional(), + allowEmpty: z => + z + .boolean({ + description: 'Allow an empty commit to be created.', + }) + .optional(), }, output: { projectid: z => @@ -107,6 +113,7 @@ export const createGitlabRepoPushAction = (options: { sourcePath, token, commitAction, + allowEmpty, } = ctx.input; const { owner, repo, project } = parseRepoUrl(repoUrl, integrations); @@ -217,6 +224,7 @@ export const createGitlabRepoPushAction = (options: { branchName, ctx.input.commitMessage, actions, + ...(allowEmpty !== undefined ? [{ allowEmpty } as object] : []), ); return commit.id; }, From 8474da5694aa3cb86713a4b3df4815c2b0c82f31 Mon Sep 17 00:00:00 2001 From: ElaineDeMattosSilvaB Date: Thu, 26 Mar 2026 21:07:18 +0100 Subject: [PATCH 015/434] feat: add api report and changeset Signed-off-by: ElaineDeMattosSilvaB --- .changeset/deep-kings-strive.md | 5 +++++ plugins/scaffolder-backend-module-gitlab/report.api.md | 9 +++++---- 2 files changed, 10 insertions(+), 4 deletions(-) create mode 100644 .changeset/deep-kings-strive.md diff --git a/.changeset/deep-kings-strive.md b/.changeset/deep-kings-strive.md new file mode 100644 index 0000000000..afe7415ec5 --- /dev/null +++ b/.changeset/deep-kings-strive.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-scaffolder-backend-module-gitlab': patch +--- + +Added `allowEmpty` input option to the `gitlab:repo:push` action, allowing empty commits. Required from GitLab 18.8 or later. diff --git a/plugins/scaffolder-backend-module-gitlab/report.api.md b/plugins/scaffolder-backend-module-gitlab/report.api.md index a5a4de0f8b..0ab38c52d8 100644 --- a/plugins/scaffolder-backend-module-gitlab/report.api.md +++ b/plugins/scaffolder-backend-module-gitlab/report.api.md @@ -69,7 +69,7 @@ export const createGitlabIssueAction: (options: { discussionToResolve?: string | undefined; epicId?: number | undefined; labels?: string | undefined; - issueType?: 'issue' | 'task' | 'incident' | 'test_case' | undefined; + issueType?: 'issue' | 'incident' | 'test_case' | 'task' | undefined; mergeRequestToResolveDiscussionsOf?: number | undefined; milestoneId?: number | undefined; weight?: number | undefined; @@ -155,6 +155,7 @@ export const createGitlabRepoPushAction: (options: { targetPath?: string | undefined; token?: string | undefined; commitAction?: 'auto' | 'update' | 'create' | 'delete' | undefined; + allowEmpty?: boolean | undefined; }, { projectid: string; @@ -211,7 +212,7 @@ export function createPublishGitlabAction(options: { visibility?: 'internal' | 'private' | 'public' | undefined; path?: string | undefined; description?: string | undefined; - merge_method?: 'merge' | 'rebase_merge' | 'ff' | undefined; + merge_method?: 'merge' | 'ff' | 'rebase_merge' | undefined; topics?: string[] | undefined; auto_devops_enabled?: boolean | undefined; only_allow_merge_if_pipeline_succeeds?: boolean | undefined; @@ -273,7 +274,7 @@ export const createPublishGitlabMergeRequestAction: (options: { sourcePath?: string | undefined; targetPath?: string | undefined; token?: string | undefined; - commitAction?: 'auto' | 'update' | 'create' | 'delete' | 'skip' | undefined; + commitAction?: 'auto' | 'update' | 'skip' | 'create' | 'delete' | undefined; projectid?: string | undefined; removeSourceBranch?: boolean | undefined; assignee?: string | undefined; @@ -324,7 +325,7 @@ export const editGitlabIssueAction: (options: { discussionLocked?: boolean | undefined; dueDate?: string | undefined; epicId?: number | undefined; - issueType?: 'issue' | 'task' | 'incident' | 'test_case' | undefined; + issueType?: 'issue' | 'incident' | 'test_case' | 'task' | undefined; labels?: string | undefined; milestoneId?: number | undefined; removeLabels?: string | undefined; From 74fb37f72966d71d313a6734c4fadaf96226a375 Mon Sep 17 00:00:00 2001 From: ElaineDeMattosSilvaB Date: Thu, 26 Mar 2026 21:40:44 +0100 Subject: [PATCH 016/434] fix: pass argument explicitly Signed-off-by: ElaineDeMattosSilvaB --- .../src/actions/gitlabRepoPush.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/scaffolder-backend-module-gitlab/src/actions/gitlabRepoPush.ts b/plugins/scaffolder-backend-module-gitlab/src/actions/gitlabRepoPush.ts index 1bf817aac8..a4ecdaa564 100644 --- a/plugins/scaffolder-backend-module-gitlab/src/actions/gitlabRepoPush.ts +++ b/plugins/scaffolder-backend-module-gitlab/src/actions/gitlabRepoPush.ts @@ -224,7 +224,7 @@ export const createGitlabRepoPushAction = (options: { branchName, ctx.input.commitMessage, actions, - ...(allowEmpty !== undefined ? [{ allowEmpty } as object] : []), + allowEmpty !== undefined ? ({ allowEmpty } as any) : undefined, ); return commit.id; }, From b07ce3da626419200c2ea6ba8af15b2bf769a9ff Mon Sep 17 00:00:00 2001 From: ElaineDeMattosSilvaB Date: Thu, 26 Mar 2026 21:58:08 +0100 Subject: [PATCH 017/434] fix: tests Signed-off-by: ElaineDeMattosSilvaB --- .../src/actions/gitlabRepoPush.test.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/plugins/scaffolder-backend-module-gitlab/src/actions/gitlabRepoPush.test.ts b/plugins/scaffolder-backend-module-gitlab/src/actions/gitlabRepoPush.test.ts index 8005c0791b..559f41ddcd 100644 --- a/plugins/scaffolder-backend-module-gitlab/src/actions/gitlabRepoPush.test.ts +++ b/plugins/scaffolder-backend-module-gitlab/src/actions/gitlabRepoPush.test.ts @@ -107,6 +107,7 @@ describe('createGitLabCommit', () => { execute_filemode: false, }, ], + undefined, ); expect(ctx.output).toHaveBeenCalledWith( 'commitHash', @@ -146,6 +147,7 @@ describe('createGitLabCommit', () => { execute_filemode: false, }, ], + undefined, ); expect(ctx.output).toHaveBeenCalledWith( 'commitHash', @@ -183,6 +185,7 @@ describe('createGitLabCommit', () => { execute_filemode: false, }, ], + undefined, ); expect(ctx.output).toHaveBeenCalledWith( 'commitHash', @@ -220,6 +223,7 @@ describe('createGitLabCommit', () => { execute_filemode: false, }, ], + undefined, ); expect(ctx.output).toHaveBeenCalledWith( 'commitHash', @@ -263,6 +267,7 @@ describe('createGitLabCommit', () => { execute_filemode: false, }, ], + undefined, ); expect(ctx.output).toHaveBeenCalledWith( 'commitHash', @@ -305,6 +310,7 @@ describe('createGitLabCommit', () => { execute_filemode: false, }, ], + undefined, ); expect(ctx.output).toHaveBeenCalledWith( 'commitHash', @@ -369,6 +375,7 @@ describe('createGitLabCommit', () => { execute_filemode: false, }, ], + undefined, ); expect(ctx.output).toHaveBeenCalledWith( 'commitHash', From d7b27f68599378171e9397389fd6b2dfb521ec70 Mon Sep 17 00:00:00 2001 From: ElaineDeMattosSilvaB Date: Thu, 26 Mar 2026 22:06:40 +0100 Subject: [PATCH 018/434] fix: tests again Signed-off-by: ElaineDeMattosSilvaB --- .../src/actions/gitlabRepoPush.test.ts | 103 ++++++++++++++++-- 1 file changed, 96 insertions(+), 7 deletions(-) diff --git a/plugins/scaffolder-backend-module-gitlab/src/actions/gitlabRepoPush.test.ts b/plugins/scaffolder-backend-module-gitlab/src/actions/gitlabRepoPush.test.ts index 559f41ddcd..c0fef779cd 100644 --- a/plugins/scaffolder-backend-module-gitlab/src/actions/gitlabRepoPush.test.ts +++ b/plugins/scaffolder-backend-module-gitlab/src/actions/gitlabRepoPush.test.ts @@ -57,6 +57,11 @@ describe('createGitLabCommit', () => { beforeEach(() => { mockDir.clear(); + jest.clearAllMocks(); + + mockGitlabClient.Commits.create.mockResolvedValue({ + id: 'bb6bce457ed069a38ef8d16ef38602972c7735c5', + }); const config = new ConfigReader({ integrations: { @@ -107,7 +112,6 @@ describe('createGitLabCommit', () => { execute_filemode: false, }, ], - undefined, ); expect(ctx.output).toHaveBeenCalledWith( 'commitHash', @@ -147,7 +151,6 @@ describe('createGitLabCommit', () => { execute_filemode: false, }, ], - undefined, ); expect(ctx.output).toHaveBeenCalledWith( 'commitHash', @@ -185,7 +188,6 @@ describe('createGitLabCommit', () => { execute_filemode: false, }, ], - undefined, ); expect(ctx.output).toHaveBeenCalledWith( 'commitHash', @@ -223,7 +225,6 @@ describe('createGitLabCommit', () => { execute_filemode: false, }, ], - undefined, ); expect(ctx.output).toHaveBeenCalledWith( 'commitHash', @@ -267,7 +268,6 @@ describe('createGitLabCommit', () => { execute_filemode: false, }, ], - undefined, ); expect(ctx.output).toHaveBeenCalledWith( 'commitHash', @@ -310,7 +310,6 @@ describe('createGitLabCommit', () => { execute_filemode: false, }, ], - undefined, ); expect(ctx.output).toHaveBeenCalledWith( 'commitHash', @@ -375,7 +374,6 @@ describe('createGitLabCommit', () => { execute_filemode: false, }, ], - undefined, ); expect(ctx.output).toHaveBeenCalledWith( 'commitHash', @@ -454,4 +452,95 @@ describe('createGitLabCommit', () => { ); }); }); + + describe('allowEmpty parameter', () => { + it('passes allowEmpty: true to Commits.create when specified', async () => { + const input = { + repoUrl: 'gitlab.com?repo=repo&owner=owner', + commitMessage: 'Empty commit', + branchName: 'some-branch', + allowEmpty: true, + }; + mockDir.setContent({ + [workspacePath]: {}, + }); + + const ctx = createMockActionContext({ input, workspacePath }); + await instance.handler(ctx); + + expect(mockGitlabClient.Commits.create).toHaveBeenCalledWith( + 'owner/repo', + 'some-branch', + 'Empty commit', + [], + { allowEmpty: true }, + ); + }); + + it('does not pass 5th argument when allowEmpty is not specified', async () => { + const input = { + repoUrl: 'gitlab.com?repo=repo&owner=owner', + commitMessage: 'Normal commit', + branchName: 'some-branch', + }; + mockDir.setContent({ + [workspacePath]: { + 'foo.txt': 'content', + }, + }); + + const ctx = createMockActionContext({ input, workspacePath }); + await instance.handler(ctx); + + expect(mockGitlabClient.Commits.create).toHaveBeenCalledWith( + 'owner/repo', + 'some-branch', + 'Normal commit', + [ + { + action: 'create', + filePath: 'foo.txt', + content: 'Y29udGVudA==', + encoding: 'base64', + execute_filemode: false, + }, + ], + ); + // Verify only 4 arguments were passed (no 5th) + expect(mockGitlabClient.Commits.create.mock.calls[0]).toHaveLength(4); + }); + + it('passes allowEmpty: false to Commits.create when explicitly set', async () => { + const input = { + repoUrl: 'gitlab.com?repo=repo&owner=owner', + commitMessage: 'Commit with files', + branchName: 'some-branch', + allowEmpty: false, + }; + mockDir.setContent({ + [workspacePath]: { + 'foo.txt': 'content', + }, + }); + + const ctx = createMockActionContext({ input, workspacePath }); + await instance.handler(ctx); + + expect(mockGitlabClient.Commits.create).toHaveBeenCalledWith( + 'owner/repo', + 'some-branch', + 'Commit with files', + [ + { + action: 'create', + filePath: 'foo.txt', + content: 'Y29udGVudA==', + encoding: 'base64', + execute_filemode: false, + }, + ], + { allowEmpty: false }, + ); + }); + }); }); From a99c5abb0a8bec7654ef1f0fd09b86efe33359e1 Mon Sep 17 00:00:00 2001 From: ElaineDeMattosSilvaB Date: Fri, 27 Mar 2026 09:40:11 +0100 Subject: [PATCH 019/434] fix: avoid compatibility issues when allowEmpty not set Signed-off-by: ElaineDeMattosSilvaB --- .../src/actions/gitlabRepoPush.ts | 22 +++++++++++++------ 1 file changed, 15 insertions(+), 7 deletions(-) diff --git a/plugins/scaffolder-backend-module-gitlab/src/actions/gitlabRepoPush.ts b/plugins/scaffolder-backend-module-gitlab/src/actions/gitlabRepoPush.ts index a4ecdaa564..51ca19d1d0 100644 --- a/plugins/scaffolder-backend-module-gitlab/src/actions/gitlabRepoPush.ts +++ b/plugins/scaffolder-backend-module-gitlab/src/actions/gitlabRepoPush.ts @@ -219,13 +219,21 @@ export const createGitlabRepoPushAction = (options: { const commitId = await ctx.checkpoint({ key: `commit.create.${repoID}.${branchName}`, fn: async () => { - const commit = await api.Commits.create( - repoID, - branchName, - ctx.input.commitMessage, - actions, - allowEmpty !== undefined ? ({ allowEmpty } as any) : undefined, - ); + const commit = + allowEmpty !== undefined + ? await api.Commits.create( + repoID, + branchName, + ctx.input.commitMessage, + actions, + { allowEmpty } as any, + ) + : await api.Commits.create( + repoID, + branchName, + ctx.input.commitMessage, + actions, + ); return commit.id; }, }); From edb652db89d921180866db1ef698f3322c35e75f Mon Sep 17 00:00:00 2001 From: ElaineDeMattosSilvaB Date: Fri, 27 Mar 2026 10:03:37 +0100 Subject: [PATCH 020/434] fix: add api reports again Signed-off-by: ElaineDeMattosSilvaB --- plugins/scaffolder-backend-module-gitlab/report.api.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/plugins/scaffolder-backend-module-gitlab/report.api.md b/plugins/scaffolder-backend-module-gitlab/report.api.md index 0ab38c52d8..e634b76221 100644 --- a/plugins/scaffolder-backend-module-gitlab/report.api.md +++ b/plugins/scaffolder-backend-module-gitlab/report.api.md @@ -69,7 +69,7 @@ export const createGitlabIssueAction: (options: { discussionToResolve?: string | undefined; epicId?: number | undefined; labels?: string | undefined; - issueType?: 'issue' | 'incident' | 'test_case' | 'task' | undefined; + issueType?: 'issue' | 'task' | 'incident' | 'test_case' | undefined; mergeRequestToResolveDiscussionsOf?: number | undefined; milestoneId?: number | undefined; weight?: number | undefined; @@ -274,7 +274,7 @@ export const createPublishGitlabMergeRequestAction: (options: { sourcePath?: string | undefined; targetPath?: string | undefined; token?: string | undefined; - commitAction?: 'auto' | 'update' | 'skip' | 'create' | 'delete' | undefined; + commitAction?: 'auto' | 'update' | 'create' | 'delete' | 'skip' | undefined; projectid?: string | undefined; removeSourceBranch?: boolean | undefined; assignee?: string | undefined; @@ -325,7 +325,7 @@ export const editGitlabIssueAction: (options: { discussionLocked?: boolean | undefined; dueDate?: string | undefined; epicId?: number | undefined; - issueType?: 'issue' | 'incident' | 'test_case' | 'task' | undefined; + issueType?: 'issue' | 'task' | 'incident' | 'test_case' | undefined; labels?: string | undefined; milestoneId?: number | undefined; removeLabels?: string | undefined; From 4df1dc5ce1ac8b9083bfc6196f26998f28a74ae0 Mon Sep 17 00:00:00 2001 From: ElaineDeMattosSilvaB Date: Fri, 27 Mar 2026 10:56:04 +0100 Subject: [PATCH 021/434] fix: try and fix report api Signed-off-by: ElaineDeMattosSilvaB --- plugins/scaffolder-backend-module-gitlab/report.api.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/scaffolder-backend-module-gitlab/report.api.md b/plugins/scaffolder-backend-module-gitlab/report.api.md index e634b76221..e05d0af2f7 100644 --- a/plugins/scaffolder-backend-module-gitlab/report.api.md +++ b/plugins/scaffolder-backend-module-gitlab/report.api.md @@ -212,7 +212,7 @@ export function createPublishGitlabAction(options: { visibility?: 'internal' | 'private' | 'public' | undefined; path?: string | undefined; description?: string | undefined; - merge_method?: 'merge' | 'ff' | 'rebase_merge' | undefined; + merge_method?: 'merge' | 'rebase_merge' | 'ff' | undefined; topics?: string[] | undefined; auto_devops_enabled?: boolean | undefined; only_allow_merge_if_pipeline_succeeds?: boolean | undefined; From 93cb19acee11f111447180c6e81cd2878c9c5406 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 27 Mar 2026 14:28:15 +0000 Subject: [PATCH 022/434] build(deps): bump brace-expansion from 1.1.12 to 1.1.13 in /microsite Bumps [brace-expansion](https://github.com/juliangruber/brace-expansion) from 1.1.12 to 1.1.13. - [Release notes](https://github.com/juliangruber/brace-expansion/releases) - [Commits](https://github.com/juliangruber/brace-expansion/compare/v1.1.12...v1.1.13) --- updated-dependencies: - dependency-name: brace-expansion dependency-version: 1.1.13 dependency-type: indirect ... Signed-off-by: dependabot[bot] --- microsite/yarn.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/microsite/yarn.lock b/microsite/yarn.lock index 3f17fdd373..e43adb4a02 100644 --- a/microsite/yarn.lock +++ b/microsite/yarn.lock @@ -5529,12 +5529,12 @@ __metadata: linkType: hard "brace-expansion@npm:^1.1.7": - version: 1.1.12 - resolution: "brace-expansion@npm:1.1.12" + version: 1.1.13 + resolution: "brace-expansion@npm:1.1.13" dependencies: balanced-match: "npm:^1.0.0" concat-map: "npm:0.0.1" - checksum: 10/12cb6d6310629e3048cadb003e1aca4d8c9bb5c67c3c321bafdd7e7a50155de081f78ea3e0ed92ecc75a9015e784f301efc8132383132f4f7904ad1ac529c562 + checksum: 10/b5f4329fdbe9d2e25fa250c8f866ebd054ba946179426e99b86dcccddabdb1d481f0e40ee5430032e62a7d0a6c2837605ace6783d015aa1d65d85ca72154d936 languageName: node linkType: hard From 58510dee5ca979c617c57069290f9c544f6d7b5b Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 27 Mar 2026 14:30:59 +0000 Subject: [PATCH 023/434] build(deps): bump brace-expansion from 1.1.12 to 1.1.13 Bumps [brace-expansion](https://github.com/juliangruber/brace-expansion) from 1.1.12 to 1.1.13. - [Release notes](https://github.com/juliangruber/brace-expansion/releases) - [Commits](https://github.com/juliangruber/brace-expansion/compare/v1.1.12...v1.1.13) --- updated-dependencies: - dependency-name: brace-expansion dependency-version: 1.1.13 dependency-type: indirect ... Signed-off-by: dependabot[bot] --- yarn.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/yarn.lock b/yarn.lock index ce85b14419..c379094ec7 100644 --- a/yarn.lock +++ b/yarn.lock @@ -25776,12 +25776,12 @@ __metadata: linkType: hard "brace-expansion@npm:^1.1.7": - version: 1.1.12 - resolution: "brace-expansion@npm:1.1.12" + version: 1.1.13 + resolution: "brace-expansion@npm:1.1.13" dependencies: balanced-match: "npm:^1.0.0" concat-map: "npm:0.0.1" - checksum: 10/12cb6d6310629e3048cadb003e1aca4d8c9bb5c67c3c321bafdd7e7a50155de081f78ea3e0ed92ecc75a9015e784f301efc8132383132f4f7904ad1ac529c562 + checksum: 10/b5f4329fdbe9d2e25fa250c8f866ebd054ba946179426e99b86dcccddabdb1d481f0e40ee5430032e62a7d0a6c2837605ace6783d015aa1d65d85ca72154d936 languageName: node linkType: hard From 5ef8d166cbbac0806efb57748033d7844505eb80 Mon Sep 17 00:00:00 2001 From: Karthik Date: Fri, 21 Nov 2025 10:26:20 +0530 Subject: [PATCH 024/434] feat(techdocs): add app-config option to disable external font download Signed-off-by: Karthik --- .changeset/kind-files-press.md | 6 ++ docs/features/techdocs/how-to-guides.md | 32 ++++++- plugins/techdocs-backend/config.d.ts | 8 ++ .../src/stages/generate/helpers.test.ts | 96 +++++++++++++++++++ .../src/stages/generate/mkdocsPatchers.ts | 40 ++++++++ .../src/stages/generate/techdocs.ts | 7 ++ .../src/stages/generate/types.ts | 1 + 7 files changed, 188 insertions(+), 2 deletions(-) create mode 100644 .changeset/kind-files-press.md diff --git a/.changeset/kind-files-press.md b/.changeset/kind-files-press.md new file mode 100644 index 0000000000..edc19b7a76 --- /dev/null +++ b/.changeset/kind-files-press.md @@ -0,0 +1,6 @@ +--- +'@backstage/plugin-techdocs-backend': minor +'@backstage/plugin-techdocs-node': minor +--- + +Add support for disabling external font downloads via app-config option `techdocs.generator.mkdocs.disableExternalFonts`, useful for air-gapped Backstage instances. diff --git a/docs/features/techdocs/how-to-guides.md b/docs/features/techdocs/how-to-guides.md index 33faf44841..27725e50fa 100644 --- a/docs/features/techdocs/how-to-guides.md +++ b/docs/features/techdocs/how-to-guides.md @@ -551,7 +551,31 @@ Done! You now have support for TechDocs in your own software template! ### Prevent download of Google fonts -If your Backstage instance does not have internet access, the generation will fail. TechDocs tries to download the Roboto font from Google. You can disable it by adding the following lines to mkdocs.yaml: +If your Backstage instance does not have internet access, the generation will fail. TechDocs tries to download the Roboto font from Google. You can disable it by configuring your `app-config.yaml` or by manually updating your `mkdocs.yml` file. + +#### Using app-config + +Add the following configuration to your `app-config.yaml` to automatically +disable external font downloads for all TechDocs sites: + +```yaml +techdocs: + generator: + mkdocs: + disableExternalFonts: true +``` + +This configuration will automatically patch the `mkdocs.yml` file during the +generation process. If no `theme` section exists, it will create one with `name: +material` and `font: false`. If a `theme` section exists but `font` is not +configured, it will add `font: false` to the existing theme. If `font` is +already explicitly configured in your `mkdocs.yml`, the patcher will respect +your file-level configuration and not override it. + +#### Manual configuration in mkdocs.yml + +Alternatively, you can manually add the following configuration to your `mkdocs.yaml` +file: ```yaml theme: @@ -561,7 +585,11 @@ theme: :::note Note -The addition `name: material` is necessary. Otherwise it will not work +The addition `name: material` is necessary. Otherwise it will not work. + +If you explicitly set `font: true` or `font: false` in your `mkdocs.yml`, the +app-config patcher will respect that setting and not override it. The patcher +only adds `font: false` when the `font` property is not already configured. ::: diff --git a/plugins/techdocs-backend/config.d.ts b/plugins/techdocs-backend/config.d.ts index 87369f2ccd..65e536bfd5 100644 --- a/plugins/techdocs-backend/config.d.ts +++ b/plugins/techdocs-backend/config.d.ts @@ -76,6 +76,14 @@ export interface Config { * @see https://www.mkdocs.org/user-guide/configuration/#hooks */ dangerouslyAllowAdditionalKeys?: string[]; + + /** + * Disable external fonts for all TechDocs sites. + * If not set, the default value is false. + * If set to true, the external font will be disabled for all TechDocs sites. + * If set to false, the external font will be enabled for all TechDocs sites. + */ + disableExternalFonts?: boolean; }; }; diff --git a/plugins/techdocs-node/src/stages/generate/helpers.test.ts b/plugins/techdocs-node/src/stages/generate/helpers.test.ts index 1f198b43e9..8f6331b621 100644 --- a/plugins/techdocs-node/src/stages/generate/helpers.test.ts +++ b/plugins/techdocs-node/src/stages/generate/helpers.test.ts @@ -37,6 +37,7 @@ import { patchMkdocsYmlPreBuild, patchMkdocsYmlWithPlugins, sanitizeMkdocsYml, + patchMkdocsYmlWithFontDisabled } from './mkdocsPatchers'; import yaml from 'js-yaml'; @@ -468,6 +469,101 @@ describe('helpers', () => { }); }); + describe('patchMkdocsYmlWithFontDisabled', () => { + beforeEach(() => { + mockDir.setContent({ + 'mkdocs_without_theme.yml': `site_name: Test Site +docs_dir: docs +`, + 'mkdocs_with_theme_no_font.yml': `site_name: Test Site +docs_dir: docs +theme: + name: material +`, + 'mkdocs_with_theme_font_true.yml': `site_name: Test Site +docs_dir: docs +theme: + name: material + font: true +`, + 'mkdocs_with_theme_font_false.yml': `site_name: Test Site +docs_dir: docs +theme: + name: material + font: false +`, + }); + }); + + it('should create theme section with font disabled when no theme exists', async () => { + await patchMkdocsYmlWithFontDisabled( + mockDir.resolve('mkdocs_without_theme.yml'), + mockLogger, + ); + + const updatedMkdocsYml = await fs.readFile( + mockDir.resolve('mkdocs_without_theme.yml'), + ); + const parsedYml = yaml.load(updatedMkdocsYml.toString()) as { + theme?: { name?: string; font?: boolean }; + }; + expect(parsedYml.theme).toBeDefined(); + expect(parsedYml.theme?.name).toBe('material'); + expect(parsedYml.theme?.font).toBe(false); + }); + + it('should add font: false when theme exists but font is not configured', async () => { + await patchMkdocsYmlWithFontDisabled( + mockDir.resolve('mkdocs_with_theme_no_font.yml'), + mockLogger, + ); + + const updatedMkdocsYml = await fs.readFile( + mockDir.resolve('mkdocs_with_theme_no_font.yml'), + ); + const parsedYml = yaml.load(updatedMkdocsYml.toString()) as { + theme?: { name?: string; font?: boolean }; + }; + expect(parsedYml.theme).toBeDefined(); + expect(parsedYml.theme?.name).toBe('material'); + expect(parsedYml.theme?.font).toBe(false); + }); + + it('should not override font when font is already set to true', async () => { + await patchMkdocsYmlWithFontDisabled( + mockDir.resolve('mkdocs_with_theme_font_true.yml'), + mockLogger, + ); + + const updatedMkdocsYml = await fs.readFile( + mockDir.resolve('mkdocs_with_theme_font_true.yml'), + ); + const parsedYml = yaml.load(updatedMkdocsYml.toString()) as { + theme?: { name?: string; font?: boolean }; + }; + expect(parsedYml.theme).toBeDefined(); + expect(parsedYml.theme?.name).toBe('material'); + expect(parsedYml.theme?.font).toBe(true); + }); + + it('should not override font when font is already set to false', async () => { + await patchMkdocsYmlWithFontDisabled( + mockDir.resolve('mkdocs_with_theme_font_false.yml'), + mockLogger, + ); + + const updatedMkdocsYml = await fs.readFile( + mockDir.resolve('mkdocs_with_theme_font_false.yml'), + ); + const parsedYml = yaml.load(updatedMkdocsYml.toString()) as { + theme?: { name?: string; font?: boolean }; + }; + expect(parsedYml.theme).toBeDefined(); + expect(parsedYml.theme?.name).toBe('material'); + expect(parsedYml.theme?.font).toBe(false); + }); + }); + describe('patchIndexPreBuild', () => { afterEach(() => { warn.mockClear(); diff --git a/plugins/techdocs-node/src/stages/generate/mkdocsPatchers.ts b/plugins/techdocs-node/src/stages/generate/mkdocsPatchers.ts index d676237b01..5f87a85c9d 100644 --- a/plugins/techdocs-node/src/stages/generate/mkdocsPatchers.ts +++ b/plugins/techdocs-node/src/stages/generate/mkdocsPatchers.ts @@ -25,11 +25,17 @@ import { assertError } from '@backstage/errors'; import { ScmIntegrationRegistry } from '@backstage/integration'; import { LoggerService } from '@backstage/backend-plugin-api'; +const MATERIAL_THEME = 'material'; + type MkDocsObject = { plugins?: string[]; docs_dir: string; repo_url?: string; edit_uri?: string; + theme?: { + name?: string; + font?: boolean; + }; }; const patchMkdocsFile = async ( @@ -185,6 +191,39 @@ export const patchMkdocsYmlWithPlugins = async ( }); }; +/** + * Disable external font download for the material theme. + * @param mkdocsYmlPath - Absolute path to mkdocs.yml or equivalent of a docs site + * @param logger + */ +export const patchMkdocsYmlWithFontDisabled = async ( + mkdocsYmlPath: string, + logger: LoggerService, +) => { + await patchMkdocsFile(mkdocsYmlPath, logger, mkdocsYml => { + if (!('theme' in mkdocsYml)) { + // No theme section exists, create it with font disabled + mkdocsYml.theme = { + name: MATERIAL_THEME, + font: false, + }; + return true; + } + + // Theme section exists, check if font is not configured, add font: false + if ( + mkdocsYml.theme && + typeof mkdocsYml.theme === 'object' && + !('font' in mkdocsYml.theme) + ) { + mkdocsYml.theme.font = false; + return true; + } + + return false; + }); +}; + /** * Sanitize mkdocs.yml by keeping only allowed configuration keys. * @@ -249,3 +288,4 @@ export const sanitizeMkdocsYml = async ( return true; }); }; + diff --git a/plugins/techdocs-node/src/stages/generate/techdocs.ts b/plugins/techdocs-node/src/stages/generate/techdocs.ts index 8a764eb391..905d2e3cb9 100644 --- a/plugins/techdocs-node/src/stages/generate/techdocs.ts +++ b/plugins/techdocs-node/src/stages/generate/techdocs.ts @@ -32,6 +32,7 @@ import { import { patchMkdocsYmlPreBuild, + patchMkdocsYmlWithFontDisabled, patchMkdocsYmlWithPlugins, sanitizeMkdocsYml, } from './mkdocsPatchers'; @@ -150,6 +151,9 @@ export class TechdocsGenerator implements GeneratorBase { } await patchMkdocsYmlWithPlugins(mkdocsYmlPath, childLogger, defaultPlugins); + if (this.options.disableExternalFonts) { + await patchMkdocsYmlWithFontDisabled(mkdocsYmlPath, childLogger); + } // Directories to bind on container const mountDirs = { @@ -264,5 +268,8 @@ export function readGeneratorConfig( dangerouslyAllowAdditionalKeys: config.getOptionalStringArray( 'techdocs.generator.mkdocs.dangerouslyAllowAdditionalKeys', ), + disableExternalFonts: config.getOptionalBoolean( + 'techdocs.generator.mkdocs.disableExternalFonts' + ), }; } diff --git a/plugins/techdocs-node/src/stages/generate/types.ts b/plugins/techdocs-node/src/stages/generate/types.ts index b716e9510b..65243129ed 100644 --- a/plugins/techdocs-node/src/stages/generate/types.ts +++ b/plugins/techdocs-node/src/stages/generate/types.ts @@ -46,6 +46,7 @@ export type GeneratorConfig = { legacyCopyReadmeMdToIndexMd?: boolean; defaultPlugins?: string[]; dangerouslyAllowAdditionalKeys?: string[]; + disableExternalFonts?: boolean; }; /** From 329f5920b68093f59bb8a45f7e64af678c5f5c2c Mon Sep 17 00:00:00 2001 From: Karthik Date: Tue, 25 Nov 2025 14:16:44 +0530 Subject: [PATCH 025/434] feat(techdocs): add techdocs-cli option to disable external font download Signed-off-by: Karthik --- .changeset/funny-animals-prove.md | 5 +++++ .../vale/config/vocabularies/Backstage/accept.txt | 1 + docs/features/techdocs/cli.md | 2 ++ docs/features/techdocs/how-to-guides.md | 14 +++++++++++++- packages/techdocs-cli/cli-report.md | 1 + .../techdocs-cli/src/commands/generate/generate.ts | 2 ++ packages/techdocs-cli/src/commands/index.ts | 5 +++++ plugins/techdocs-backend/config.d.ts | 2 +- .../src/stages/generate/helpers.test.ts | 2 +- .../src/stages/generate/mkdocsPatchers.ts | 14 ++++++++++++-- .../techdocs-node/src/stages/generate/techdocs.ts | 2 +- 11 files changed, 44 insertions(+), 6 deletions(-) create mode 100644 .changeset/funny-animals-prove.md diff --git a/.changeset/funny-animals-prove.md b/.changeset/funny-animals-prove.md new file mode 100644 index 0000000000..e4116a8d23 --- /dev/null +++ b/.changeset/funny-animals-prove.md @@ -0,0 +1,5 @@ +--- +'@techdocs/cli': minor +--- + +Add support for disabling external font downloads via techdocs-cli `techdocs-cli generate --disableExternalFonts`, useful for air-gapped Backstage instances. diff --git a/.github/vale/config/vocabularies/Backstage/accept.txt b/.github/vale/config/vocabularies/Backstage/accept.txt index 0a96735646..c5a5deb5e0 100644 --- a/.github/vale/config/vocabularies/Backstage/accept.txt +++ b/.github/vale/config/vocabularies/Backstage/accept.txt @@ -587,3 +587,4 @@ zod Zolotusky zoomable zsh +patcher \ No newline at end of file diff --git a/docs/features/techdocs/cli.md b/docs/features/techdocs/cli.md index fa3d9148e2..616acb01e6 100644 --- a/docs/features/techdocs/cli.md +++ b/docs/features/techdocs/cli.md @@ -149,6 +149,8 @@ Options: Defaults to false, which means that the techdocs-core plugin is always added to the mkdocs file. --legacyCopyReadmeMdToIndexMd Attempt to ensure an index.md exists falling back to using /README.md or README.md in case a default /index.md is not provided. (default: false) + --disableExternalFonts Disable external font downloads for all TechDocs sites. Useful for air-gapped environments + where Google fonts cannot be accessed. (default: false) --runAsDefaultUser Bypass setting the container user as the same user and group id as host for Linux and MacOS (default: false) -v, --verbose Enable verbose output. (default: false) -h, --help display help for command diff --git a/docs/features/techdocs/how-to-guides.md b/docs/features/techdocs/how-to-guides.md index 27725e50fa..e8b1304115 100644 --- a/docs/features/techdocs/how-to-guides.md +++ b/docs/features/techdocs/how-to-guides.md @@ -574,7 +574,7 @@ your file-level configuration and not override it. #### Manual configuration in mkdocs.yml -Alternatively, you can manually add the following configuration to your `mkdocs.yaml` +Alternatively, you can manually add the following configuration to your `mkdocs.yml` file: ```yaml @@ -593,6 +593,18 @@ only adds `font: false` when the `font` property is not already configured. ::: +#### Using techdocs-cli in CI/CD + +When generating TechDocs sites in CI/CD workflows using `techdocs-cli`, you can +use the `--disableExternalFonts` flag: + +```bash +techdocs-cli generate --disableExternalFonts +``` + +This will automatically patch the `mkdocs.yml` file during the generation +process, just like the `app-config.yaml` option does for local generation. + ## How to enable iframes in TechDocs TechDocs uses the [DOMPurify](https://github.com/cure53/DOMPurify) library to diff --git a/packages/techdocs-cli/cli-report.md b/packages/techdocs-cli/cli-report.md index 667c4fb6b6..f682072cd7 100644 --- a/packages/techdocs-cli/cli-report.md +++ b/packages/techdocs-cli/cli-report.md @@ -27,6 +27,7 @@ Usage: techdocs-cli generate|build [options] Options: --defaultPlugin [defaultPlugins...] + --disableExternalFonts --docker-image --etag --legacyCopyReadmeMdToIndexMd diff --git a/packages/techdocs-cli/src/commands/generate/generate.ts b/packages/techdocs-cli/src/commands/generate/generate.ts index 9a009aff00..b5b38b13d7 100644 --- a/packages/techdocs-cli/src/commands/generate/generate.ts +++ b/packages/techdocs-cli/src/commands/generate/generate.ts @@ -42,6 +42,7 @@ export default async function generate(opts: OptionValues) { const dockerImage = opts.dockerImage; const pullImage = opts.pull; const legacyCopyReadmeMdToIndexMd = opts.legacyCopyReadmeMdToIndexMd; + const disableExternalFonts = opts.disableExternalFonts; const defaultPlugins = opts.defaultPlugin; logger.info(`Using source dir ${sourceDir}`); @@ -64,6 +65,7 @@ export default async function generate(opts: OptionValues) { mkdocs: { legacyCopyReadmeMdToIndexMd, omitTechdocsCorePlugin, + disableExternalFonts, defaultPlugins, }, }, diff --git a/packages/techdocs-cli/src/commands/index.ts b/packages/techdocs-cli/src/commands/index.ts index e69cf4671a..c169815afc 100644 --- a/packages/techdocs-cli/src/commands/index.ts +++ b/packages/techdocs-cli/src/commands/index.ts @@ -70,6 +70,11 @@ export function registerCommands(program: Command) { 'Attempt to ensure an index.md exists falling back to using /README.md or README.md in case a default /index.md is not provided.', false, ) + .option( + '--disableExternalFonts', + 'Disable external font downloads by default by setting theme.font: false in mkdocs.yml when not already configured. Useful for air-gapped environments where Google fonts cannot be accessed.', + false, + ) .option( '--defaultPlugin [defaultPlugins...]', 'Plugins which should be added automatically to the mkdocs.yaml file', diff --git a/plugins/techdocs-backend/config.d.ts b/plugins/techdocs-backend/config.d.ts index 65e536bfd5..64c7fdc35f 100644 --- a/plugins/techdocs-backend/config.d.ts +++ b/plugins/techdocs-backend/config.d.ts @@ -76,7 +76,7 @@ export interface Config { * @see https://www.mkdocs.org/user-guide/configuration/#hooks */ dangerouslyAllowAdditionalKeys?: string[]; - + /** * Disable external fonts for all TechDocs sites. * If not set, the default value is false. diff --git a/plugins/techdocs-node/src/stages/generate/helpers.test.ts b/plugins/techdocs-node/src/stages/generate/helpers.test.ts index 8f6331b621..23eeaaa20e 100644 --- a/plugins/techdocs-node/src/stages/generate/helpers.test.ts +++ b/plugins/techdocs-node/src/stages/generate/helpers.test.ts @@ -37,7 +37,7 @@ import { patchMkdocsYmlPreBuild, patchMkdocsYmlWithPlugins, sanitizeMkdocsYml, - patchMkdocsYmlWithFontDisabled + patchMkdocsYmlWithFontDisabled, } from './mkdocsPatchers'; import yaml from 'js-yaml'; diff --git a/plugins/techdocs-node/src/stages/generate/mkdocsPatchers.ts b/plugins/techdocs-node/src/stages/generate/mkdocsPatchers.ts index 5f87a85c9d..28c5928de6 100644 --- a/plugins/techdocs-node/src/stages/generate/mkdocsPatchers.ts +++ b/plugins/techdocs-node/src/stages/generate/mkdocsPatchers.ts @@ -210,16 +210,27 @@ export const patchMkdocsYmlWithFontDisabled = async ( return true; } - // Theme section exists, check if font is not configured, add font: false + // Theme section exists. Only modify it when the configured theme is Material if ( mkdocsYml.theme && typeof mkdocsYml.theme === 'object' && + (mkdocsYml.theme as any).name === MATERIAL_THEME && !('font' in mkdocsYml.theme) ) { mkdocsYml.theme.font = false; return true; } + if ( + mkdocsYml.theme && + typeof mkdocsYml.theme === 'object' && + (mkdocsYml.theme as any).name !== MATERIAL_THEME + ) { + logger.debug( + 'mkdocs.yml theme is not "material"; skipping font disabling patch', + ); + } + return false; }); }; @@ -288,4 +299,3 @@ export const sanitizeMkdocsYml = async ( return true; }); }; - diff --git a/plugins/techdocs-node/src/stages/generate/techdocs.ts b/plugins/techdocs-node/src/stages/generate/techdocs.ts index 905d2e3cb9..e3492f2043 100644 --- a/plugins/techdocs-node/src/stages/generate/techdocs.ts +++ b/plugins/techdocs-node/src/stages/generate/techdocs.ts @@ -269,7 +269,7 @@ export function readGeneratorConfig( 'techdocs.generator.mkdocs.dangerouslyAllowAdditionalKeys', ), disableExternalFonts: config.getOptionalBoolean( - 'techdocs.generator.mkdocs.disableExternalFonts' + 'techdocs.generator.mkdocs.disableExternalFonts', ), }; } From 7e42c62038a70804121090a35939743db350c914 Mon Sep 17 00:00:00 2001 From: Benjamin Janssens Date: Tue, 7 Apr 2026 14:27:12 +0200 Subject: [PATCH 026/434] fix(app): add check for disabled nav items to discovery of pages Signed-off-by: Benjamin Janssens --- .changeset/grumpy-experts-leave.md | 5 +++++ plugins/app/src/extensions/AppNav.tsx | 6 ++++++ 2 files changed, 11 insertions(+) create mode 100644 .changeset/grumpy-experts-leave.md diff --git a/.changeset/grumpy-experts-leave.md b/.changeset/grumpy-experts-leave.md new file mode 100644 index 0000000000..7504fe6428 --- /dev/null +++ b/.changeset/grumpy-experts-leave.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-app': patch +--- + +Fixed a regression that caused disabled nav items to appear in the navigation bar. diff --git a/plugins/app/src/extensions/AppNav.tsx b/plugins/app/src/extensions/AppNav.tsx index 09203303e5..caeeb9df0c 100644 --- a/plugins/app/src/extensions/AppNav.tsx +++ b/plugins/app/src/extensions/AppNav.tsx @@ -188,6 +188,12 @@ function NavContentRenderer(props: { return []; } + const navItemNodeId = node.spec.id.replace(/^page:/, 'nav-item:'); + const navItemNode = tree.nodes.get(navItemNodeId); + if (navItemNode?.spec.disabled) { + return []; + } + const routeRef = node.instance.getData(coreExtensionData.routeRef); if (!routeRef) { return []; From 1ecbb1837ea47a4de2e17272c6f7979c239c90ff Mon Sep 17 00:00:00 2001 From: Benjamin Janssens Date: Tue, 7 Apr 2026 15:26:35 +0200 Subject: [PATCH 027/434] test(app): add tests Signed-off-by: Benjamin Janssens --- plugins/app/src/extensions/AppNav.test.tsx | 100 +++++++++++++++++++++ 1 file changed, 100 insertions(+) create mode 100644 plugins/app/src/extensions/AppNav.test.tsx diff --git a/plugins/app/src/extensions/AppNav.test.tsx b/plugins/app/src/extensions/AppNav.test.tsx new file mode 100644 index 0000000000..387c75a87a --- /dev/null +++ b/plugins/app/src/extensions/AppNav.test.tsx @@ -0,0 +1,100 @@ +/* + * Copyright 2026 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { screen, waitFor, within } from '@testing-library/react'; +import { renderTestApp } from '@backstage/frontend-test-utils'; +import { + PageBlueprint, + NavItemBlueprint, + createRouteRef, +} from '@backstage/frontend-plugin-api'; + +const DEFAULT_CONFIG = { + app: { baseUrl: 'http://localhost:3000' }, + backend: { baseUrl: 'http://localhost:7007' }, +}; + +const mockPage = PageBlueprint.make({ + name: 'my-plugin', + params: { + title: 'My Plugin', + icon: icon, + path: '/my-plugin', + routeRef: createRouteRef(), + }, +}); + +const mockNavItem = NavItemBlueprint.make({ + name: 'my-plugin', + params: { + title: 'My Plugin', + icon: () => icon, + routeRef: createRouteRef(), + }, +}); + +describe('AppNav', () => { + it('should show a nav item for a page with an enabled nav-item extension', async () => { + renderTestApp({ + extensions: [mockPage, mockNavItem], + config: DEFAULT_CONFIG, + }); + + await waitFor(() => { + expect( + within(screen.getByRole('navigation')).getByText('My Plugin'), + ).toBeInTheDocument(); + }); + }); + + it('should hide a nav item when its nav-item extension is disabled via config', async () => { + renderTestApp({ + extensions: [mockPage, mockNavItem], + config: { + ...DEFAULT_CONFIG, + app: { + ...DEFAULT_CONFIG.app, + extensions: [{ 'nav-item:test/my-plugin': false }], + }, + }, + }); + + await waitFor(() => { + expect( + within(screen.getByRole('navigation')).queryByText('My Plugin'), + ).not.toBeInTheDocument(); + }); + }); + + it('should still show a nav item for a page without a nav-item extension', async () => { + renderTestApp({ + extensions: [mockPage], + config: { + ...DEFAULT_CONFIG, + app: { + ...DEFAULT_CONFIG.app, + extensions: [{ 'nav-item:test/my-plugin': false }], + }, + }, + }); + + await waitFor(() => { + expect( + within(screen.getByRole('navigation')).getByText('My Plugin'), + ).toBeInTheDocument(); + }); + }); +}); From a2cef3080a199031ac874af1ead689ef4981115e Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 8 Apr 2026 01:01:33 +0000 Subject: [PATCH 028/434] chore(deps): bump @hono/node-server from 1.19.10 to 1.19.13 Bumps [@hono/node-server](https://github.com/honojs/node-server) from 1.19.10 to 1.19.13. - [Release notes](https://github.com/honojs/node-server/releases) - [Commits](https://github.com/honojs/node-server/compare/v1.19.10...v1.19.13) --- updated-dependencies: - dependency-name: "@hono/node-server" dependency-version: 1.19.13 dependency-type: indirect ... Signed-off-by: dependabot[bot] --- yarn.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/yarn.lock b/yarn.lock index f55727338f..bfb9204cf9 100644 --- a/yarn.lock +++ b/yarn.lock @@ -9840,11 +9840,11 @@ __metadata: linkType: hard "@hono/node-server@npm:^1.19.9": - version: 1.19.10 - resolution: "@hono/node-server@npm:1.19.10" + version: 1.19.13 + resolution: "@hono/node-server@npm:1.19.13" peerDependencies: hono: ^4 - checksum: 10/3f4a386bf51d2eb0224522e7485052f8ca6485e9f59fb46cef8fe9578c0d2f35ae26900ed69b6c368250e79944239c70934912f85b833045b14ef19be557aa13 + checksum: 10/67e453b0f6c0854244f3985eecbcf657b7d0247eb9404f2f507fec9242d8f8077ab8b0a212de1ac147290e76167e3598ca0df38940f6b63bde67f58526aba253 languageName: node linkType: hard From 79d88e534e66ac05b278793134c2d6d14cc46862 Mon Sep 17 00:00:00 2001 From: Aditya Kumar Date: Wed, 8 Apr 2026 09:15:21 +0530 Subject: [PATCH 029/434] Making suggested changes Signed-off-by: Aditya Kumar --- .changeset/purple-insects-cross.md | 7 ------- .../Content/ConfigContent/ConfigContent.tsx | 19 ++++++++++++++----- 2 files changed, 14 insertions(+), 12 deletions(-) diff --git a/.changeset/purple-insects-cross.md b/.changeset/purple-insects-cross.md index b0d08b62a3..d0371e18ed 100644 --- a/.changeset/purple-insects-cross.md +++ b/.changeset/purple-insects-cross.md @@ -3,10 +3,3 @@ --- Migrated `ConfigContent` component from Material UI to Backstage UI (BUI). - -Replaced MUI components with their BUI equivalents: - -- `Box`, `Paper` → `Box` with `bg` and `p` props -- `Typography` → `Text` with `as="p"` -- `Alert` → `Alert` with `status` and `title` props -- Removed `makeStyles`/`createStyles` in favor of BUI spacing props diff --git a/plugins/devtools/src/components/Content/ConfigContent/ConfigContent.tsx b/plugins/devtools/src/components/Content/ConfigContent/ConfigContent.tsx index 868dab2353..bb5902c7ac 100644 --- a/plugins/devtools/src/components/Content/ConfigContent/ConfigContent.tsx +++ b/plugins/devtools/src/components/Content/ConfigContent/ConfigContent.tsx @@ -15,8 +15,9 @@ */ import { Progress, WarningPanel } from '@backstage/core-components'; +import { appThemeApiRef, useApi } from '@backstage/core-plugin-api'; import { Alert, Box, Text } from '@backstage/ui'; -import { useTheme } from '@material-ui/core/styles'; +import useObservable from 'react-use/esm/useObservable'; import ReactJson from 'react-json-view'; import { useConfig } from '../../../hooks'; import { ConfigError } from '@backstage/plugin-devtools-common'; @@ -41,17 +42,25 @@ export const WarningContent = ({ error }: { error: ConfigError }) => { /** @public */ export const ConfigContent = () => { - const theme = useTheme(); + const appThemeApi = useApi(appThemeApiRef); + const activeThemeId = useObservable( + appThemeApi.activeThemeId$(), + appThemeApi.getActiveThemeId(), + ); + const activeTheme = appThemeApi + .getInstalledThemes() + .find(t => t.id === activeThemeId); + const isDark = activeTheme?.variant === 'dark'; const { configInfo, loading, error } = useConfig(); if (loading) { return ; } else if (error) { - return ; + return ; } if (!configInfo) { - return ; + return ; } return ( @@ -68,7 +77,7 @@ export const ConfigContent = () => { src={configInfo.config as object} name="config" enableClipboard={false} - theme={theme.palette.type === 'dark' ? 'chalk' : 'rjv-default'} + theme={isDark ? 'chalk' : 'rjv-default'} /> From 0f7bde56b4bba2c427d5608e9c1731518aa2446f Mon Sep 17 00:00:00 2001 From: aramissennyeydd Date: Fri, 10 Apr 2026 15:50:22 -0400 Subject: [PATCH 030/434] address PR feedback Signed-off-by: aramissennyeydd --- docs/golden-path/deployment/001-docker.md | 25 +---------------------- 1 file changed, 1 insertion(+), 24 deletions(-) diff --git a/docs/golden-path/deployment/001-docker.md b/docs/golden-path/deployment/001-docker.md index 80549e0021..e93106ecf3 100644 --- a/docs/golden-path/deployment/001-docker.md +++ b/docs/golden-path/deployment/001-docker.md @@ -40,7 +40,7 @@ and produces better caching behavior. From the root of your repository: ```shell -yarn install --immutable +yarn install yarn tsc yarn build:backend ``` @@ -60,29 +60,6 @@ docker run -it -p 7007:7007 backstage You should see logs in your terminal and be able to open `http://localhost:7007` in your browser. -## Running the build in CI - -While you can run this build locally, you will typically run it as part of your -CI pipeline. A GitHub Actions workflow might look like this: - -```yaml -jobs: - build: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 - with: - node-version: 24 - - run: yarn install --immutable - - run: yarn tsc - - run: yarn build:backend - - run: docker image build . -f packages/backend/Dockerfile --tag backstage -``` - -After building, push the image to your container registry (ECR, GCR, Docker -Hub, etc.) so that your deployment platform can pull it. - :::tip Troubleshooting If you run into build issues, two Docker flags can help: From 2ce3506e93a12284d31fd0c0ba6e07758dafa618 Mon Sep 17 00:00:00 2001 From: aramissennyeydd Date: Fri, 10 Apr 2026 15:57:47 -0400 Subject: [PATCH 031/434] fix copilot comments Signed-off-by: aramissennyeydd --- docs/golden-path/deployment/006-monitoring.md | 8 +++++--- docs/golden-path/deployment/index.md | 8 ++++---- 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/docs/golden-path/deployment/006-monitoring.md b/docs/golden-path/deployment/006-monitoring.md index e082a74788..d2cd097e60 100644 --- a/docs/golden-path/deployment/006-monitoring.md +++ b/docs/golden-path/deployment/006-monitoring.md @@ -28,7 +28,8 @@ into your backend. ### Key metrics to monitor -Backstage plugins emit metrics that give you insight into system health: +Backstage plugins emit metrics that give you insight into system health. +Common examples include: - `catalog_entities_count` - Total number of entities in the catalog. - `catalog.processed.entities.count` - Number of entities processed. @@ -36,8 +37,9 @@ Backstage plugins emit metrics that give you insight into system health: - `scaffolder.task.count` - Number of scaffolder tasks run. - `scaffolder.task.duration` - Time taken by scaffolder tasks. -These metrics help you set up alerts for things like processing backlogs or -unusually slow scaffolder runs. +The specific metric names may vary depending on which plugins you have +installed and their versions. These examples help you set up alerts for +things like processing backlogs or unusually slow scaffolder runs. ### Health checks diff --git a/docs/golden-path/deployment/index.md b/docs/golden-path/deployment/index.md index 77df39a986..417bc4267e 100644 --- a/docs/golden-path/deployment/index.md +++ b/docs/golden-path/deployment/index.md @@ -4,7 +4,7 @@ title: Deploying Backstage to production description: A guided path for deploying your Backstage app to production --- -### Prerequisites +## Prerequisites - You have completed the [create-app golden path](../create-app/index.md) and have a working Backstage app. @@ -13,7 +13,7 @@ description: A guided path for deploying your Backstage app to production - You have a general understanding of how your company builds and deploys software. -### What should I get out of this guide? +## What should I get out of this guide? This guide walks through everything you need to get your Backstage instance running in a production environment. By the end, you will have: @@ -25,7 +25,7 @@ running in a production environment. By the end, you will have: - Monitoring and observability set up with OpenTelemetry. - An understanding of how to scale your deployment as usage grows. -### Structure +## Structure We start with the Docker image since that is the foundation of any deployment. Then we set up the two critical pre-deploy dependencies: a database and @@ -33,6 +33,6 @@ authentication. After that, we walk through deploying to Kubernetes and discuss other deployment options. Finally, we cover operational topics like configuration management, monitoring, and scaling. -### Next steps +## Next steps - [Building the Docker image](./001-docker.md) From 303954b154ccf38b530ab819cbc6d0243b1696f0 Mon Sep 17 00:00:00 2001 From: Hellgren Heikki Date: Thu, 28 Aug 2025 14:56:49 +0300 Subject: [PATCH 032/434] feat(search): add actions to query search engine the authorized search engine initialization had to be moved to plugin from router as it should also be used in the query action if permissions are enabled. Signed-off-by: Hellgren Heikki --- .changeset/fluffy-brooms-sniff.md | 5 + app-config.yaml | 1 + docs/ai/well-known-actions.md | 4 + .../src/actions/createQueryAction.test.ts | 315 ++++++++++++++++++ .../src/actions/createQueryAction.ts | 149 +++++++++ plugins/search-backend/src/actions/index.ts | 34 ++ plugins/search-backend/src/plugin.ts | 26 +- plugins/search-backend/src/service/router.ts | 76 +---- .../src/utils/search_result_utils.ts | 62 ++++ 9 files changed, 600 insertions(+), 72 deletions(-) create mode 100644 .changeset/fluffy-brooms-sniff.md create mode 100644 plugins/search-backend/src/actions/createQueryAction.test.ts create mode 100644 plugins/search-backend/src/actions/createQueryAction.ts create mode 100644 plugins/search-backend/src/actions/index.ts create mode 100644 plugins/search-backend/src/utils/search_result_utils.ts diff --git a/.changeset/fluffy-brooms-sniff.md b/.changeset/fluffy-brooms-sniff.md new file mode 100644 index 0000000000..dc89a26226 --- /dev/null +++ b/.changeset/fluffy-brooms-sniff.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-search-backend': patch +--- + +Added action for search backend to query search engine using the actions registry diff --git a/app-config.yaml b/app-config.yaml index 86391154f3..8c93e85f68 100644 --- a/app-config.yaml +++ b/app-config.yaml @@ -191,6 +191,7 @@ backend: pluginSources: - catalog - scaffolder + - search # See README.md in the proxy-backend plugin for information on the configuration format proxy: endpoints: diff --git a/docs/ai/well-known-actions.md b/docs/ai/well-known-actions.md index 547f7d31b7..4c9ea87c5a 100644 --- a/docs/ai/well-known-actions.md +++ b/docs/ai/well-known-actions.md @@ -33,3 +33,7 @@ This is a (non-exhaustive) list of actions that are known to be part of the Acti - `scaffolder.list-scaffolder-tasks` (List Scaffolder Tasks): This allows you to list scaffolder tasks that have been created. - `scaffolder.execute-template` (Execute Scaffolder Template): Executes a Scaffolder template with its template ref and input parameter values. - `scaffolder.get-scaffolder-task-logs` (Get Scaffolder Task Logs): This allows you to fetch the logs of a given scaffolder task. + +### Search + +- `search.query` (Query Search Engine): Query the Backstage search engine for documents across all or selected document types. diff --git a/plugins/search-backend/src/actions/createQueryAction.test.ts b/plugins/search-backend/src/actions/createQueryAction.test.ts new file mode 100644 index 0000000000..54ec4c06a7 --- /dev/null +++ b/plugins/search-backend/src/actions/createQueryAction.test.ts @@ -0,0 +1,315 @@ +/* + * Copyright 2025 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { createQueryAction } from './createQueryAction'; +import { actionsRegistryServiceMock } from '@backstage/backend-test-utils/alpha'; + +describe('createQueryAction', () => { + const mockLogger = { + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + debug: jest.fn(), + child: jest.fn(), + } as any; + + it('returns results from the search engine', async () => { + const mockActionsRegistry = actionsRegistryServiceMock(); + const mockGetDocumentTypes = jest.fn().mockReturnValue({ + a: {}, + b: {}, + c: {}, + }); + const mockSearchIndexService = { + getDocumentTypes: mockGetDocumentTypes, + } as any; + const mockQuery = jest.fn().mockResolvedValue({ + results: [ + { + type: 'test', + document: { + title: 'Result 1', + text: 'Text', + location: 'http://example.com/a', + }, + }, + { + type: 'test', + document: { + title: 'Result 2', + text: 'Text', + location: 'https://example.com/b', + }, + }, + ], + nextPageCursor: 'next', + previousPageCursor: 'prev', + numberOfResults: 2, + }); + const mockEngine = { query: mockQuery } as any; + createQueryAction({ + engine: mockEngine, + actionsRegistry: mockActionsRegistry, + searchIndexService: mockSearchIndexService, + logger: mockLogger, + }); + + const result = await mockActionsRegistry.invoke({ + id: 'test:query', + input: { term: 'foo' }, + }); + + expect(result.output).toEqual({ + results: [ + { + type: 'test', + document: { + title: 'Result 1', + text: 'Text', + location: 'http://example.com/a', + }, + }, + { + type: 'test', + document: { + title: 'Result 2', + text: 'Text', + location: 'https://example.com/b', + }, + }, + ], + nextPageCursor: 'next', + totalItems: 2, + hasMoreResults: true, + }); + }); + + it('sets hasMoreResults to false when there is no nextPageCursor', async () => { + const mockActionsRegistry = actionsRegistryServiceMock(); + const mockSearchIndexService = { + getDocumentTypes: jest.fn().mockReturnValue({ a: {} }), + } as any; + const mockQuery = jest.fn().mockResolvedValue({ + results: [ + { + type: 'test', + document: { + title: 'Result 1', + text: 'Text', + location: 'http://example.com/a', + }, + }, + ], + numberOfResults: 1, + }); + const mockEngine = { query: mockQuery } as any; + createQueryAction({ + engine: mockEngine, + actionsRegistry: mockActionsRegistry, + searchIndexService: mockSearchIndexService, + logger: mockLogger, + }); + + const result = await mockActionsRegistry.invoke({ + id: 'test:query', + input: { term: 'foo' }, + }); + + expect(result.output).toMatchObject({ hasMoreResults: false }); + }); + + it('strips the authorization field from documents', async () => { + const mockActionsRegistry = actionsRegistryServiceMock(); + const mockSearchIndexService = { + getDocumentTypes: jest.fn().mockReturnValue({ a: {} }), + } as any; + const mockQuery = jest.fn().mockResolvedValue({ + results: [ + { + type: 'test', + document: { + title: 'Secret', + text: 'Text', + location: 'http://example.com/secret', + authorization: { resourceRef: 'component:default/secret' }, + }, + }, + ], + }); + const mockEngine = { query: mockQuery } as any; + createQueryAction({ + engine: mockEngine, + actionsRegistry: mockActionsRegistry, + searchIndexService: mockSearchIndexService, + logger: mockLogger, + }); + + const result = await mockActionsRegistry.invoke({ + id: 'test:query', + input: { term: 'secret' }, + }); + + const output = result.output as any; + expect(output.results).toHaveLength(1); + expect(output.results[0].document).not.toHaveProperty('authorization'); + }); + + it('accepts nested objects in filters', async () => { + const mockActionsRegistry = actionsRegistryServiceMock(); + const mockSearchIndexService = { + getDocumentTypes: jest.fn().mockReturnValue({ a: {} }), + } as any; + const mockQuery = jest.fn().mockResolvedValue({ results: [] }); + const mockEngine = { query: mockQuery } as any; + createQueryAction({ + engine: mockEngine, + actionsRegistry: mockActionsRegistry, + searchIndexService: mockSearchIndexService, + logger: mockLogger, + }); + + await mockActionsRegistry.invoke({ + id: 'test:query', + input: { + term: 'foo', + filters: { kind: 'Component', metadata: { namespace: 'default' } }, + } as any, + }); + + expect(mockQuery).toHaveBeenCalledWith( + expect.objectContaining({ + filters: { kind: 'Component', metadata: { namespace: 'default' } }, + }), + expect.anything(), + ); + }); + + it('registers successfully when no document types are registered', () => { + const mockActionsRegistry = actionsRegistryServiceMock(); + const mockSearchIndexService = { + getDocumentTypes: jest.fn().mockReturnValue({}), + } as any; + const mockEngine = { query: jest.fn() } as any; + + expect(() => + createQueryAction({ + engine: mockEngine, + actionsRegistry: mockActionsRegistry, + searchIndexService: mockSearchIndexService, + logger: mockLogger, + }), + ).not.toThrow(); + }); + + it('forwards input to the search engine', async () => { + const mockActionsRegistry = actionsRegistryServiceMock(); + const mockGetDocumentTypes = jest.fn().mockReturnValue({ + a: {}, + b: {}, + c: {}, + }); + const mockSearchIndexService = { + getDocumentTypes: mockGetDocumentTypes, + } as any; + const mockQuery = jest.fn().mockResolvedValue({ results: [] }); + const mockEngine = { query: mockQuery } as any; + createQueryAction({ + engine: mockEngine, + actionsRegistry: mockActionsRegistry, + searchIndexService: mockSearchIndexService, + logger: mockLogger, + }); + + await mockActionsRegistry.invoke({ + id: 'test:query', + input: { + term: 'foo', + types: ['a'], + filters: { x: '1234' }, + pageLimit: 5, + pageCursor: 'abc', + } as any, + }); + expect(mockQuery).toHaveBeenCalledWith( + { + term: 'foo', + types: ['a'], + filters: { x: '1234' }, + pageLimit: 5, + pageCursor: 'abc', + }, + expect.anything(), + ); + }); + + it('filters out results with unsafe location protocols', async () => { + const mockActionsRegistry = actionsRegistryServiceMock(); + const mockSearchIndexService = { + getDocumentTypes: jest.fn().mockReturnValue({ a: {} }), + } as any; + const infoSpy = jest.fn(); + const logger = { ...mockLogger, info: infoSpy } as any; + const mockQuery = jest.fn().mockResolvedValue({ + results: [ + { + type: 'test', + document: { + title: 'Safe', + text: 'Text', + location: 'https://example.com/safe', + }, + }, + { + type: 'test', + document: { + title: 'XSS', + text: 'Text', + location: 'javascript' + ':alert(1)', + }, + }, + { + type: 'test', + document: { + title: 'Data URI', + text: 'Text', + location: 'data' + ':text/html,

hi

', + }, + }, + ], + }); + const mockEngine = { query: mockQuery } as any; + createQueryAction({ + engine: mockEngine, + actionsRegistry: mockActionsRegistry, + searchIndexService: mockSearchIndexService, + logger, + }); + + const result = await mockActionsRegistry.invoke({ + id: 'test:query', + input: { term: 'foo' }, + }); + + const output = result.output as any; + expect(output.results).toHaveLength(1); + expect(output.results[0].document.title).toBe('Safe'); + expect(infoSpy).toHaveBeenCalledTimes(2); + expect(infoSpy).toHaveBeenCalledWith( + // eslint-disable-next-line no-script-url + expect.stringContaining('javascript:'), + ); + expect(infoSpy).toHaveBeenCalledWith(expect.stringContaining('data:')); + }); +}); diff --git a/plugins/search-backend/src/actions/createQueryAction.ts b/plugins/search-backend/src/actions/createQueryAction.ts new file mode 100644 index 0000000000..4dc7b17b59 --- /dev/null +++ b/plugins/search-backend/src/actions/createQueryAction.ts @@ -0,0 +1,149 @@ +/* + * Copyright 2025 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { z as zv3 } from 'zod/v3'; +import { JsonObject, JsonValue } from '@backstage/types'; +import { ActionsRegistryService } from '@backstage/backend-plugin-api/alpha'; +import { LoggerService } from '@backstage/backend-plugin-api'; +import { SearchEngine } from '@backstage/plugin-search-backend-node'; +import { SearchIndexService } from '@backstage/plugin-search-backend-node/alpha'; +import { filterResultSet, toSearchResults } from '../utils/search_result_utils'; + +const jsonObjectSchema: zv3.ZodSchema = zv3.lazy(() => { + const jsonValueSchema: zv3.ZodSchema = zv3.lazy(() => + zv3.union([ + zv3.string(), + zv3.number(), + zv3.boolean(), + zv3.null(), + zv3.array(jsonValueSchema), + jsonObjectSchema, + ]), + ); + return zv3.record(jsonValueSchema); +}); + +export const createQueryAction = ({ + engine, + searchIndexService, + actionsRegistry, + logger, +}: { + engine: SearchEngine; + searchIndexService: SearchIndexService; + actionsRegistry: ActionsRegistryService; + logger: LoggerService; +}) => { + const allTypes = Object.keys(searchIndexService.getDocumentTypes()); + const quotedTypes = allTypes.map(t => JSON.stringify(t)).join(', '); + const typesDescription = + allTypes.length > 0 + ? `The supported document types are: ${quotedTypes}.` + : ''; + actionsRegistry.register({ + name: 'query', + title: 'Query Search Engine', + description: ` +This allows you to query the search engine for documents. +You can search across all document types, or restrict the query to specific types. +${typesDescription} +Pagination is supported via the \`pageLimit\` and \`pageCursor\` parameters and is enabled by default with limit of 10. +Results are returned in a paginated format, along with \`pageCursor\` for navigating to the next page of results. + `, + attributes: { + readOnly: true, + }, + schema: { + input: z => + z.object({ + term: z.string().describe('The search term to query for'), + types: (allTypes.length > 0 + ? z.array(z.enum(allTypes as [string, ...string[]])) + : z.array(z.string()) + ) + .optional() + .describe('The types of documents to query for'), + filters: jsonObjectSchema + .optional() + .describe('The filters to apply to the query'), + pageLimit: z + .number() + .optional() + .describe( + 'The number of results to return per page. Defaults to 10.', + ) + .default(10), + pageCursor: z + .string() + .optional() + .describe('The cursor for the next page of results'), + }), + output: z => + z.object({ + results: z + .array( + z.object({ + type: z.string().describe('Document type'), + document: z + .object({ + title: z.string().describe('Document title'), + text: z.string().describe('Document text content'), + location: z + .string() + .describe('Document location, e.g. URL'), + }) + .passthrough(), + highlight: z + .object({ + preTag: z.string(), + postTag: z.string(), + fields: z.record(z.string(), z.string()), + }) + .optional() + .describe('Optional result highlight that matches the query'), + rank: z.number().optional().describe('The rank of the result'), + }), + ) + .describe('The search results'), + nextPageCursor: z + .string() + .optional() + .describe('The cursor for the next page of results, if any'), + totalItems: z + .number() + .optional() + .describe('The total number of results found'), + hasMoreResults: z + .boolean() + .describe('Whether there are more results'), + }), + }, + action: async ({ input, credentials }) => { + const resp = await engine.query(input, { credentials }); + const { results, nextPageCursor, numberOfResults } = filterResultSet( + toSearchResults(resp), + logger, + ); + return { + output: { + results, + nextPageCursor, + totalItems: numberOfResults, + hasMoreResults: nextPageCursor !== undefined, + }, + }; + }, + }); +}; diff --git a/plugins/search-backend/src/actions/index.ts b/plugins/search-backend/src/actions/index.ts new file mode 100644 index 0000000000..b56ed48b48 --- /dev/null +++ b/plugins/search-backend/src/actions/index.ts @@ -0,0 +1,34 @@ +/* + * Copyright 2025 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { SearchEngine } from '@backstage/plugin-search-backend-node'; +import { ActionsRegistryService } from '@backstage/backend-plugin-api/alpha'; +import { createQueryAction } from './createQueryAction.ts'; +import { SearchIndexService } from '@backstage/plugin-search-backend-node/alpha'; +import { LifecycleService, LoggerService } from '@backstage/backend-plugin-api'; + +export const registerActions = (options: { + engine: SearchEngine; + actionsRegistry: ActionsRegistryService; + lifecycle: LifecycleService; + searchIndexService: SearchIndexService; + logger: LoggerService; +}) => { + const { lifecycle } = options; + // Register after startup to ensure all document types are registered + lifecycle.addStartupHook(() => { + createQueryAction(options); + }); +}; diff --git a/plugins/search-backend/src/plugin.ts b/plugins/search-backend/src/plugin.ts index 6f4f0dc4cb..eca81282ba 100644 --- a/plugins/search-backend/src/plugin.ts +++ b/plugins/search-backend/src/plugin.ts @@ -33,6 +33,9 @@ import { } from '@backstage/plugin-search-backend-node/alpha'; import { createRouter } from './service/router'; +import { AuthorizedSearchEngine } from './service/AuthorizedSearchEngine.ts'; +import { registerActions } from './actions'; +import { actionsRegistryServiceRef } from '@backstage/backend-plugin-api/alpha'; class SearchIndexRegistry implements SearchIndexRegistryExtensionPoint { private collators: RegisterCollatorParameters[] = []; @@ -100,6 +103,7 @@ export default createBackendPlugin({ httpAuth: coreServices.httpAuth, lifecycle: coreServices.rootLifecycle, searchIndexService: searchIndexServiceRef, + actionsRegistry: actionsRegistryServiceRef, }, async init({ config, @@ -111,6 +115,7 @@ export default createBackendPlugin({ httpAuth, lifecycle, searchIndexService, + actionsRegistry, }) { let searchEngine = searchEngineRegistry.getSearchEngine(); if (!searchEngine) { @@ -135,6 +140,16 @@ export default createBackendPlugin({ await searchIndexService.stop(); }); + const engine = config.getOptionalBoolean('permission.enabled') + ? new AuthorizedSearchEngine( + searchEngine, + searchIndexService.getDocumentTypes(), + permissions, + auth, + config, + ) + : searchEngine; + const router = await createRouter({ config, discovery, @@ -142,11 +157,18 @@ export default createBackendPlugin({ auth, httpAuth, logger, - engine: searchEngine, + engine, types: searchIndexService.getDocumentTypes(), }); - http.use(router); + + registerActions({ + engine, + actionsRegistry, + lifecycle, + searchIndexService, + logger, + }); }, }); }, diff --git a/plugins/search-backend/src/service/router.ts b/plugins/search-backend/src/service/router.ts index 2be6612c8b..16aff3aa19 100644 --- a/plugins/search-backend/src/service/router.ts +++ b/plugins/search-backend/src/service/router.ts @@ -19,18 +19,10 @@ import { z } from 'zod/v3'; import { InputError } from '@backstage/errors'; import { Config } from '@backstage/config'; import { JsonObject, JsonValue } from '@backstage/types'; -import { - PermissionAuthorizer, - PermissionEvaluator, - toPermissionEvaluator, -} from '@backstage/plugin-permission-common'; -import { - DocumentTypeInfo, - IndexableResultSet, - SearchResultSet, -} from '@backstage/plugin-search-common'; +import { PermissionEvaluator } from '@backstage/plugin-permission-common'; +import { DocumentTypeInfo } from '@backstage/plugin-search-common'; +import { filterResultSet, toSearchResults } from '../utils/search_result_utils'; import { SearchEngine } from '@backstage/plugin-search-backend-node'; -import { AuthorizedSearchEngine } from './AuthorizedSearchEngine'; import { createOpenApiRouter } from '../schema/openapi'; import { AuthService, @@ -61,7 +53,7 @@ export type RouterOptions = { engine: SearchEngine; types: Record; discovery?: DiscoveryService; - permissions: PermissionEvaluator | PermissionAuthorizer; + permissions: PermissionEvaluator; config: Config; logger: LoggerService; auth: AuthService; @@ -70,8 +62,6 @@ export type RouterOptions = { const defaultMaxPageLimit = 100; const defaultMaxTermLength = 100; -const allowedLocationProtocols = ['http:', 'https:']; - /** * @internal */ @@ -79,15 +69,7 @@ export async function createRouter( options: RouterOptions, ): Promise { const router = await createOpenApiRouter(); - const { - engine: inputEngine, - types, - permissions, - config, - logger, - auth, - httpAuth, - } = options; + const { engine, types, config, logger, auth, httpAuth } = options; const maxPageLimit = config.getOptionalNumber('search.maxPageLimit') ?? defaultMaxPageLimit; @@ -121,52 +103,6 @@ export async function createRouter( .optional(), }); - let permissionEvaluator: PermissionEvaluator; - if ('authorizeConditional' in permissions) { - permissionEvaluator = permissions as PermissionEvaluator; - } else { - logger.warn( - 'PermissionAuthorizer is deprecated. Please use an instance of PermissionEvaluator instead of PermissionAuthorizer in PluginEnvironment#permissions', - ); - permissionEvaluator = toPermissionEvaluator(permissions); - } - - const engine = config.getOptionalBoolean('permission.enabled') - ? new AuthorizedSearchEngine( - inputEngine, - types, - permissionEvaluator, - auth, - config, - ) - : inputEngine; - - const filterResultSet = ({ results, ...resultSet }: SearchResultSet) => ({ - ...resultSet, - results: results.filter(result => { - const protocol = new URL(result.document.location, 'https://example.com') - .protocol; - const isAllowed = allowedLocationProtocols.includes(protocol); - if (!isAllowed) { - logger.info( - `Rejected search result for "${result.document.title}" as location protocol "${protocol}" is unsafe`, - ); - } - return isAllowed; - }), - }); - - const toSearchResults = (resultSet: IndexableResultSet): SearchResultSet => ({ - ...resultSet, - results: resultSet.results.map(result => ({ - ...result, - document: { - ...result.document, - authorization: undefined, - }, - })), - }); - router.get('/query', async (req, res) => { const parseResult = requestSchema.passthrough().safeParse(req.query); @@ -195,7 +131,7 @@ export async function createRouter( credentials, }); - res.json(filterResultSet(toSearchResults(resultSet))); + res.json(filterResultSet(toSearchResults(resultSet), logger)); } catch (error) { // Log the error message here, but don't expose it to the user in the response logger.error( diff --git a/plugins/search-backend/src/utils/search_result_utils.ts b/plugins/search-backend/src/utils/search_result_utils.ts new file mode 100644 index 0000000000..acb754c6c4 --- /dev/null +++ b/plugins/search-backend/src/utils/search_result_utils.ts @@ -0,0 +1,62 @@ +/* + * Copyright 2025 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { + IndexableResultSet, + SearchResultSet, +} from '@backstage/plugin-search-common'; +import { LoggerService } from '@backstage/backend-plugin-api'; + +const allowedLocationProtocols = ['http:', 'https:']; + +/** + * Converts an IndexableResultSet to a SearchResultSet by stripping internal + * fields (e.g. authorization) that must not be exposed to callers. + * @internal + */ +export const toSearchResults = (resultSet: IndexableResultSet) => ({ + ...resultSet, + results: resultSet.results.map(result => ({ + ...result, + document: { + ...result.document, + authorization: undefined, + }, + })), +}); + +/** + * Filters a SearchResultSet to remove results whose document location uses an + * unsafe protocol (anything other than http: or https:). + * @internal + */ +export const filterResultSet = ( + { results, ...resultSet }: T, + logger: LoggerService, +): T => + ({ + ...resultSet, + results: results.filter(result => { + const protocol = new URL(result.document.location, 'https://example.com') + .protocol; + const isAllowed = allowedLocationProtocols.includes(protocol); + if (!isAllowed) { + logger.info( + `Rejected search result for "${result.document.title}" as location protocol "${protocol}" is unsafe`, + ); + } + return isAllowed; + }), + } as T); From 532175a0d6781c7ad65b2797121e27df3e771605 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 14 Apr 2026 01:28:37 +0000 Subject: [PATCH 033/434] chore(deps): bump follow-redirects from 1.15.6 to 1.16.0 in /microsite Bumps [follow-redirects](https://github.com/follow-redirects/follow-redirects) from 1.15.6 to 1.16.0. - [Release notes](https://github.com/follow-redirects/follow-redirects/releases) - [Commits](https://github.com/follow-redirects/follow-redirects/compare/v1.15.6...v1.16.0) --- updated-dependencies: - dependency-name: follow-redirects dependency-version: 1.16.0 dependency-type: indirect ... Signed-off-by: dependabot[bot] --- microsite/yarn.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/microsite/yarn.lock b/microsite/yarn.lock index 3f17fdd373..c0a4a00da8 100644 --- a/microsite/yarn.lock +++ b/microsite/yarn.lock @@ -7614,12 +7614,12 @@ __metadata: linkType: hard "follow-redirects@npm:^1.0.0": - version: 1.15.6 - resolution: "follow-redirects@npm:1.15.6" + version: 1.16.0 + resolution: "follow-redirects@npm:1.16.0" peerDependenciesMeta: debug: optional: true - checksum: 10/70c7612c4cab18e546e36b991bbf8009a1a41cf85354afe04b113d1117569abf760269409cb3eb842d9f7b03d62826687086b081c566ea7b1e6613cf29030bf7 + checksum: 10/3fbe3d80b3b544c22705d837aa5d4a0d07a740d913534a2620b0a004c610af4148e3b58723536dd099aaa1c9d3a155964bde9665d6e5cb331460809a1fc572fd languageName: node linkType: hard From 878c42176692f2c7dc0d740f8b31c3fd55c1e3b4 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 14 Apr 2026 10:32:58 +0000 Subject: [PATCH 034/434] chore(deps): bump follow-redirects from 1.15.11 to 1.16.0 Bumps [follow-redirects](https://github.com/follow-redirects/follow-redirects) from 1.15.11 to 1.16.0. - [Release notes](https://github.com/follow-redirects/follow-redirects/releases) - [Commits](https://github.com/follow-redirects/follow-redirects/compare/v1.15.11...v1.16.0) --- updated-dependencies: - dependency-name: follow-redirects dependency-version: 1.16.0 dependency-type: indirect ... Signed-off-by: dependabot[bot] --- yarn.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/yarn.lock b/yarn.lock index 294105994d..ad42576055 100644 --- a/yarn.lock +++ b/yarn.lock @@ -31743,12 +31743,12 @@ __metadata: linkType: hard "follow-redirects@npm:^1.0.0, follow-redirects@npm:^1.15.11": - version: 1.15.11 - resolution: "follow-redirects@npm:1.15.11" + version: 1.16.0 + resolution: "follow-redirects@npm:1.16.0" peerDependenciesMeta: debug: optional: true - checksum: 10/07372fd74b98c78cf4d417d68d41fdaa0be4dcacafffb9e67b1e3cf090bc4771515e65020651528faab238f10f9b9c0d9707d6c1574a6c0387c5de1042cde9ba + checksum: 10/3fbe3d80b3b544c22705d837aa5d4a0d07a740d913534a2620b0a004c610af4148e3b58723536dd099aaa1c9d3a155964bde9665d6e5cb331460809a1fc572fd languageName: node linkType: hard From d00a44bc120d517ac2dc6fbf088e3003093f4ae9 Mon Sep 17 00:00:00 2001 From: Shamil Ganiev Date: Tue, 14 Apr 2026 15:30:31 +0300 Subject: [PATCH 035/434] fix: use iovalkey Cluster for Valkey cluster mode Signed-off-by: Shamil Ganiev --- .changeset/fix-valkey-cluster-client.md | 9 ++ packages/backend-defaults/package.json | 1 + .../entrypoints/cache/CacheManager.test.ts | 117 +++++++++++++++++- .../src/entrypoints/cache/CacheManager.ts | 29 +++-- .../src/entrypoints/cache/types.ts | 6 +- yarn.lock | 1 + 6 files changed, 147 insertions(+), 16 deletions(-) create mode 100644 .changeset/fix-valkey-cluster-client.md diff --git a/.changeset/fix-valkey-cluster-client.md b/.changeset/fix-valkey-cluster-client.md new file mode 100644 index 0000000000..07bd60e302 --- /dev/null +++ b/.changeset/fix-valkey-cluster-client.md @@ -0,0 +1,9 @@ +--- +'@backstage/backend-defaults': patch +--- + +Fixed Valkey cluster mode to use `iovalkey`'s `Cluster` class instead of +`createCluster` from `@keyv/redis`. The previous implementation passed a +`@redis/client` `RedisCluster` instance to `@keyv/valkey`, which expects an +`iovalkey` `Cluster` instance. This caused the cluster client to not be +recognized correctly, as the two libraries have incompatible object models. diff --git a/packages/backend-defaults/package.json b/packages/backend-defaults/package.json index 33a262aadd..4f3aed7a53 100644 --- a/packages/backend-defaults/package.json +++ b/packages/backend-defaults/package.json @@ -170,6 +170,7 @@ "git-url-parse": "^15.0.0", "helmet": "^6.0.0", "infinispan": "^0.12.0", + "iovalkey": "^0.3.3", "is-glob": "^4.0.3", "jose": "^5.0.0", "keyv": "^5.2.1", diff --git a/packages/backend-defaults/src/entrypoints/cache/CacheManager.test.ts b/packages/backend-defaults/src/entrypoints/cache/CacheManager.test.ts index 323ca881c5..9a29901239 100644 --- a/packages/backend-defaults/src/entrypoints/cache/CacheManager.test.ts +++ b/packages/backend-defaults/src/entrypoints/cache/CacheManager.test.ts @@ -18,6 +18,7 @@ import { mockServices, TestCaches } from '@backstage/backend-test-utils'; import KeyvRedis, { createCluster } from '@keyv/redis'; import KeyvValkey from '@keyv/valkey'; import KeyvMemcache from '@keyv/memcache'; +import { Cluster as ValkeyCluster } from 'iovalkey'; import { CacheManager } from './CacheManager'; // This test is in a separate file because the main test file uses other mocking @@ -41,7 +42,13 @@ jest.mock('@keyv/valkey', () => { ...Actual, __esModule: true, default: jest.fn((...args: any[]) => new DefaultConstructor(...args)), - createCluster: jest.fn(), + }; +}); +jest.mock('iovalkey', () => { + const Actual = jest.requireActual('iovalkey'); + return { + ...Actual, + Cluster: jest.fn(), }; }); jest.mock('@keyv/memcache', () => { @@ -211,6 +218,8 @@ describe('CacheManager integration', () => { }); describe('CacheManager store options', () => { + afterEach(jest.clearAllMocks); + it('uses default options when no store-specific config exists', () => { const manager = CacheManager.fromConfig( mockServices.rootConfig({ @@ -307,6 +316,9 @@ describe('CacheManager store options', () => { }); it('accepts client config for clustered mode', () => { + const clusterInstance = { fake: 'cluster' }; + (createCluster as jest.Mock).mockReturnValue(clusterInstance); + const manager = CacheManager.fromConfig( mockServices.rootConfig({ data: { @@ -329,11 +341,112 @@ describe('CacheManager store options', () => { ); manager.forPlugin('p1'); - expect(KeyvRedis).toHaveBeenCalledWith(expect.anything(), { + expect(KeyvRedis).toHaveBeenCalledWith(clusterInstance, { keyPrefixSeparator: '!', }); }); + it('uses iovalkey Cluster for valkey cluster mode', () => { + const manager = CacheManager.fromConfig( + mockServices.rootConfig({ + data: { + backend: { + cache: { + store: 'valkey', + connection: 'redis://localhost:6379', + valkey: { + cluster: { + rootNodes: [ + { host: 'localhost', port: 6379 }, + { host: 'localhost', port: 6380 }, + ], + }, + }, + }, + }, + }, + }), + ); + manager.forPlugin('p1'); + + expect(ValkeyCluster).toHaveBeenCalledWith( + [ + { host: 'localhost', port: 6379 }, + { host: 'localhost', port: 6380 }, + ], + { + redisOptions: undefined, + scaleReads: undefined, + maxRedirections: undefined, + lazyConnect: undefined, + }, + ); + expect(KeyvValkey).toHaveBeenCalledWith(expect.any(Object), { + keyPrefix: undefined, + }); + }); + + it('passes valkey cluster options from config', () => { + const manager = CacheManager.fromConfig( + mockServices.rootConfig({ + data: { + backend: { + cache: { + store: 'valkey', + connection: 'redis://localhost:6379', + valkey: { + client: { keyPrefix: 'my-app:' }, + cluster: { + rootNodes: [{ host: 'localhost', port: 6379 }], + useReplicas: true, + maxCommandRedirections: 5, + }, + }, + }, + }, + }, + }), + ); + manager.forPlugin('p1'); + + expect(ValkeyCluster).toHaveBeenCalledWith( + [{ host: 'localhost', port: 6379 }], + { + redisOptions: undefined, + scaleReads: 'slave', + maxRedirections: 5, + lazyConnect: undefined, + }, + ); + expect(KeyvValkey).toHaveBeenCalledWith(expect.any(Object), { + keyPrefix: 'my-app:', + }); + }); + + it('defaults to non-clustered valkey when cluster config is missing root nodes', () => { + const manager = CacheManager.fromConfig( + mockServices.rootConfig({ + data: { + backend: { + cache: { + store: 'valkey', + connection: 'redis://localhost:6379', + valkey: { + cluster: {}, + }, + }, + }, + }, + }), + ); + manager.forPlugin('p1'); + + expect(ValkeyCluster).not.toHaveBeenCalled(); + expect(KeyvValkey).toHaveBeenCalledWith('redis://localhost:6379', { + keyPrefix: undefined, + }); + }); + it('correctly applies namespace configuration to redis and valkey stores', () => { const testCases = [ { diff --git a/packages/backend-defaults/src/entrypoints/cache/CacheManager.ts b/packages/backend-defaults/src/entrypoints/cache/CacheManager.ts index 38c0c761ed..4403d4d76a 100644 --- a/packages/backend-defaults/src/entrypoints/cache/CacheManager.ts +++ b/packages/backend-defaults/src/entrypoints/cache/CacheManager.ts @@ -240,14 +240,16 @@ export class CacheManager { valkeyOptions.cluster = { rootNodes: clusterConfig.get('rootNodes'), - defaults: clusterConfig.getOptional('defaults'), - minimizeConnections: clusterConfig.getOptionalBoolean( - 'minimizeConnections', - ), - useReplicas: clusterConfig.getOptionalBoolean('useReplicas'), - maxCommandRedirections: clusterConfig.getOptionalNumber( - 'maxCommandRedirections', - ), + options: { + redisOptions: clusterConfig.getOptional('defaults'), + scaleReads: clusterConfig.getOptionalBoolean('useReplicas') + ? 'slave' + : undefined, + maxRedirections: clusterConfig.getOptionalNumber( + 'maxCommandRedirections', + ), + lazyConnect: clusterConfig.getOptionalBoolean('minimizeConnections'), + }, }; } @@ -368,9 +370,7 @@ export class CacheManager { private createValkeyStoreFactory(): StoreFactory { const KeyvValkey = require('@keyv/valkey').default; - // `@keyv/valkey` doesn't export a `createCluster` function, but is compatible with the one from `@keyv/redis` - // See https://keyv.org/docs/storage-adapters/valkey - const { createCluster } = require('@keyv/redis'); + const { Cluster } = require('iovalkey'); const stores: Record = {}; return (pluginId, defaultTtl) => { @@ -382,8 +382,11 @@ export class CacheManager { if (!stores[pluginId]) { const valkeyOptions = this.storeOptions?.client; if (this.storeOptions?.cluster) { - // Create a Valkey cluster (Redis cluster under the hood) - const cluster = createCluster(this.storeOptions?.cluster); + // Create an iovalkey Cluster instance, which is the type that @keyv/valkey expects + const cluster = new Cluster( + this.storeOptions.cluster.rootNodes, + this.storeOptions.cluster.options, + ); stores[pluginId] = new KeyvValkey(cluster, valkeyOptions); } else { // Create a regular Valkey connection diff --git a/packages/backend-defaults/src/entrypoints/cache/types.ts b/packages/backend-defaults/src/entrypoints/cache/types.ts index 60e32e8618..7f35d516f7 100644 --- a/packages/backend-defaults/src/entrypoints/cache/types.ts +++ b/packages/backend-defaults/src/entrypoints/cache/types.ts @@ -18,6 +18,7 @@ import { LoggerService } from '@backstage/backend-plugin-api'; import { HumanDuration, durationToMilliseconds } from '@backstage/types'; import { RedisClusterOptions, KeyvRedisOptions } from '@keyv/redis'; import { KeyvValkeyOptions } from '@keyv/valkey'; +import { ClusterNode, ClusterOptions } from 'iovalkey'; /** * Options for Redis cache store. @@ -38,7 +39,10 @@ export type RedisCacheStoreOptions = { export type ValkeyCacheStoreOptions = { type: 'valkey'; client?: KeyvValkeyOptions; - cluster?: RedisClusterOptions; + cluster?: { + rootNodes: Array; + options?: ClusterOptions; + }; }; /** diff --git a/yarn.lock b/yarn.lock index 13b51ed309..992da2bdc8 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2566,6 +2566,7 @@ __metadata: helmet: "npm:^6.0.0" http-errors: "npm:^2.0.0" infinispan: "npm:^0.12.0" + iovalkey: "npm:^0.3.3" is-glob: "npm:^4.0.3" jose: "npm:^5.0.0" keyv: "npm:^5.2.1" From 54e17a0f4d9c8a8194d7109170eb2250cbf48153 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 16 Apr 2026 02:25:28 +0000 Subject: [PATCH 036/434] build(deps): bump hono from 4.12.7 to 4.12.14 Bumps [hono](https://github.com/honojs/hono) from 4.12.7 to 4.12.14. - [Release notes](https://github.com/honojs/hono/releases) - [Commits](https://github.com/honojs/hono/compare/v4.12.7...v4.12.14) --- updated-dependencies: - dependency-name: hono dependency-version: 4.12.14 dependency-type: indirect ... Signed-off-by: dependabot[bot] --- yarn.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/yarn.lock b/yarn.lock index ef3a38f251..0b5c0ab579 100644 --- a/yarn.lock +++ b/yarn.lock @@ -31732,9 +31732,9 @@ __metadata: linkType: hard "hono@npm:^4.11.4": - version: 4.12.7 - resolution: "hono@npm:4.12.7" - checksum: 10/fed37e612730491ba9456f8f68f1b8727a5298cd839fff9641a0b7a95b1e8567a05abb819d32621b40988f166b01140cf7d573c9218dee2741004f48e09564d5 + version: 4.12.14 + resolution: "hono@npm:4.12.14" + checksum: 10/2e620adb89e773a7e982dee8d8c9917f79c71b89cb051b5bc0d2aa91b5318373e90ba7a236bdc4762dc36026c1d7e5e979d6b48cf1dbbe44fc524a5c4df227d9 languageName: node linkType: hard From b2a820e624cc79838e9efbe74db493f0d61c7099 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 17 Apr 2026 05:42:52 +0000 Subject: [PATCH 037/434] chore(deps): bump protobufjs from 7.5.4 to 7.5.5 Bumps [protobufjs](https://github.com/protobufjs/protobuf.js) from 7.5.4 to 7.5.5. - [Release notes](https://github.com/protobufjs/protobuf.js/releases) - [Changelog](https://github.com/protobufjs/protobuf.js/blob/master/CHANGELOG.md) - [Commits](https://github.com/protobufjs/protobuf.js/compare/protobufjs-v7.5.4...protobufjs-v7.5.5) --- updated-dependencies: - dependency-name: protobufjs dependency-version: 7.5.5 dependency-type: indirect ... Signed-off-by: dependabot[bot] --- yarn.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/yarn.lock b/yarn.lock index e872879041..b691db4fca 100644 --- a/yarn.lock +++ b/yarn.lock @@ -41087,8 +41087,8 @@ __metadata: linkType: hard "protobufjs@npm:^7.0.0, protobufjs@npm:^7.2.5, protobufjs@npm:^7.2.6, protobufjs@npm:^7.3.2, protobufjs@npm:^7.4.0, protobufjs@npm:^7.5.3": - version: 7.5.4 - resolution: "protobufjs@npm:7.5.4" + version: 7.5.5 + resolution: "protobufjs@npm:7.5.5" dependencies: "@protobufjs/aspromise": "npm:^1.1.2" "@protobufjs/base64": "npm:^1.1.2" @@ -41102,7 +41102,7 @@ __metadata: "@protobufjs/utf8": "npm:^1.1.0" "@types/node": "npm:>=13.7.0" long: "npm:^5.0.0" - checksum: 10/88d677bb6f11a2ecec63fdd053dfe6d31120844d04e865efa9c8fbe0674cd077d6624ecfdf014018a20dcb114ae2a59c1b21966dd8073e920650c71370966439 + checksum: 10/048898023a38d22f5fc9a1bcf0dcce5cfbcd37fb00753bd72283720eee7e2cb6055b23957542e5bcdc136379af66203a2ddb8d8c39d11f73169bacf07885fedd languageName: node linkType: hard From 1933c4b81724bfe8ff517be1de3f47ef8638ed80 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maximilian=20W=C3=B6rner?= Date: Fri, 17 Apr 2026 10:34:02 +0200 Subject: [PATCH 038/434] Update GitLab URL format in old documentation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Maximilian Wörner --- docs/features/techdocs/how-to-guides--old.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/features/techdocs/how-to-guides--old.md b/docs/features/techdocs/how-to-guides--old.md index 175a8e2ee8..992124177d 100644 --- a/docs/features/techdocs/how-to-guides--old.md +++ b/docs/features/techdocs/how-to-guides--old.md @@ -92,7 +92,7 @@ the source code hosting provider. Notice that instead of the `dir:` prefix, the `url:` prefix is used instead. For example: - **GitHub**: `url:https://githubhost.com/org/repo/tree/` -- **GitLab**: `url:https://gitlabhost.com/org/repo/tree/` +- **GitLab**: `url:https://gitlabhost.com/org/repo` - **Bitbucket**: `url:https://bitbuckethost.com/project/repo/src/` - **Azure**: `url:https://azurehost.com/organization/project/_git/repository` From d0f6d9e5c0a7334d129821fa3147ad292b4bbcc2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maximilian=20W=C3=B6rner?= Date: Fri, 17 Apr 2026 10:34:28 +0200 Subject: [PATCH 039/434] Update GitLab URL format in documentation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Maximilian Wörner --- docs/features/techdocs/how-to-guides.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/features/techdocs/how-to-guides.md b/docs/features/techdocs/how-to-guides.md index 10b8270283..840d4af9bd 100644 --- a/docs/features/techdocs/how-to-guides.md +++ b/docs/features/techdocs/how-to-guides.md @@ -93,7 +93,7 @@ the source code hosting provider. Notice that instead of the `dir:` prefix, the `url:` prefix is used instead. For example: - **GitHub**: `url:https://githubhost.com/org/repo/tree/` -- **GitLab**: `url:https://gitlabhost.com/org/repo/tree/` +- **GitLab**: `url:https://gitlabhost.com/org/repo` - **Bitbucket**: `url:https://bitbuckethost.com/project/repo/src/` - **Azure**: `url:https://azurehost.com/organization/project/_git/repository` From 4fb8469d37993f66794a7dea11e812e51a2a83ff Mon Sep 17 00:00:00 2001 From: Justin Bryant Date: Fri, 17 Apr 2026 17:00:37 -0400 Subject: [PATCH 040/434] fix(SecureTemplater): return dispose function to clean up secure templater isolate vm & context Signed-off-by: Justin Bryant --- .../lib/templating/SecureTemplater.test.ts | 57 ++++++++++++------- .../src/lib/templating/SecureTemplater.ts | 13 ++++- .../builtin/fetch/templateActionHandler.ts | 26 +++++---- .../fetch/templateFileActionHandler.ts | 26 +++++---- .../tasks/NunjucksWorkflowRunner.ts | 26 +++++---- 5 files changed, 90 insertions(+), 58 deletions(-) diff --git a/plugins/scaffolder-backend/src/lib/templating/SecureTemplater.test.ts b/plugins/scaffolder-backend/src/lib/templating/SecureTemplater.test.ts index 53bf67fd87..2957848279 100644 --- a/plugins/scaffolder-backend/src/lib/templating/SecureTemplater.test.ts +++ b/plugins/scaffolder-backend/src/lib/templating/SecureTemplater.test.ts @@ -18,7 +18,7 @@ import { SecureTemplater } from './SecureTemplater'; describe('SecureTemplater', () => { it('should render some templates', async () => { - const render = await SecureTemplater.loadRenderer(); + const { render, dispose } = await SecureTemplater.loadRenderer(); expect(render('${{ test }}', { test: 'my-value' })).toBe('my-value'); expect(render('${{ test | dump }}', { test: 'my-value' })).toBe( @@ -36,13 +36,16 @@ describe('SecureTemplater', () => { test: 'my-value', }), ).toThrow(/expected name as lookup value, got ./); + dispose(); }); it('should make cookiecutter compatibility available when requested', async () => { - const renderWith = await SecureTemplater.loadRenderer({ - cookiecutterCompat: true, - }); - const renderWithout = await SecureTemplater.loadRenderer(); + const { render: renderWith, dispose: disposeWith } = + await SecureTemplater.loadRenderer({ + cookiecutterCompat: true, + }); + const { render: renderWithout, dispose: disposeWithout } = + await SecureTemplater.loadRenderer(); // Same two tests repeated to make sure switching back and forth works expect(renderWith('{{ 1 | jsonify }}', {})).toBe('1'); @@ -61,6 +64,8 @@ describe('SecureTemplater', () => { /Error: filter not found: jsonify/, ); expect(renderWith('{{ 1 | jsonify }}', {})).toBe('1'); + disposeWith(); + disposeWithout(); }); it('should make parseRepoUrl available when requested', async () => { @@ -72,10 +77,12 @@ describe('SecureTemplater', () => { const projectSlug = jest.fn(() => 'my-owner/my-repo'); - const renderWith = await SecureTemplater.loadRenderer({ - templateFilters: { parseRepoUrl, projectSlug }, - }); - const renderWithout = await SecureTemplater.loadRenderer(); + const { render: renderWith, dispose: disposeWith } = + await SecureTemplater.loadRenderer({ + templateFilters: { parseRepoUrl, projectSlug }, + }); + const { render: renderWithout, dispose: disposeWithout } = + await SecureTemplater.loadRenderer(); const ctx = { repoUrl: 'https://my-host.com/my-owner/my-repo', @@ -101,6 +108,8 @@ describe('SecureTemplater', () => { expect(parseRepoUrl.mock.calls).toEqual([ ['https://my-host.com/my-owner/my-repo'], ]); + disposeWith(); + disposeWithout(); }); it('should make additional filters available when requested', async () => { @@ -108,10 +117,12 @@ describe('SecureTemplater', () => { const mockFilter2 = jest.fn((var1, var2) => `${var1} ${var2}`); const mockFilter3 = jest.fn((var1, var2) => ({ var1, var2 })); const mockFilter4 = jest.fn(() => undefined); - const renderWith = await SecureTemplater.loadRenderer({ - templateFilters: { mockFilter1, mockFilter2, mockFilter3, mockFilter4 }, - }); - const renderWithout = await SecureTemplater.loadRenderer(); + const { render: renderWith, dispose: disposeWith } = + await SecureTemplater.loadRenderer({ + templateFilters: { mockFilter1, mockFilter2, mockFilter3, mockFilter4 }, + }); + const { render: renderWithout, dispose: disposeWithout } = + await SecureTemplater.loadRenderer(); const ctx = { inputValue: 'the input value' }; @@ -149,16 +160,20 @@ describe('SecureTemplater', () => { expect(mockFilter3.mock.calls).toEqual([ ['the input value', 'another extra arg'], ]); + disposeWith(); + disposeWithout(); }); it('should make additional globals available when requested', async () => { const mockGlobal1 = jest.fn(() => 'awesome global function'); const mockGlobal2 = 'foo'; const mockGlobal3 = 123456; const mockGlobal4 = jest.fn(() => undefined); - const renderWith = await SecureTemplater.loadRenderer({ - templateGlobals: { mockGlobal1, mockGlobal2, mockGlobal3, mockGlobal4 }, - }); - const renderWithout = await SecureTemplater.loadRenderer(); + const { render: renderWith, dispose: disposeWith } = + await SecureTemplater.loadRenderer({ + templateGlobals: { mockGlobal1, mockGlobal2, mockGlobal3, mockGlobal4 }, + }); + const { render: renderWithout, dispose: disposeWithout } = + await SecureTemplater.loadRenderer(); const ctx = {}; @@ -172,10 +187,12 @@ describe('SecureTemplater', () => { expect(() => renderWithout('${{ mockGlobal1() }}', ctx)).toThrow( /Error: Unable to call `mockGlobal1`/, ); + disposeWith(); + disposeWithout(); }); it('should not allow helpers to be rewritten', async () => { - const render = await SecureTemplater.loadRenderer({ + const { render, dispose } = await SecureTemplater.loadRenderer({ templateFilters: { parseRepoUrl: () => ({ repo: 'my-repo', @@ -202,10 +219,11 @@ describe('SecureTemplater', () => { host: 'my-host.com', }), ); + dispose(); }); it('allows pollution during a single template execution', async () => { - const render = await SecureTemplater.loadRenderer(); + const { render, dispose } = await SecureTemplater.loadRenderer(); const ctx = { x: 'foo', @@ -218,5 +236,6 @@ describe('SecureTemplater', () => { ), ).toBe(''); expect(() => render('${{ x }}', ctx)).toThrow(); + dispose(); }); }); diff --git a/plugins/scaffolder-backend/src/lib/templating/SecureTemplater.ts b/plugins/scaffolder-backend/src/lib/templating/SecureTemplater.ts index 709cb1308d..71a7a14e60 100644 --- a/plugins/scaffolder-backend/src/lib/templating/SecureTemplater.ts +++ b/plugins/scaffolder-backend/src/lib/templating/SecureTemplater.ts @@ -14,14 +14,14 @@ * limitations under the License. */ -import { Isolate } from 'isolated-vm'; import { resolvePackagePath } from '@backstage/backend-plugin-api'; import { TemplateFilter, TemplateGlobal, } from '@backstage/plugin-scaffolder-node'; -import fs from 'fs-extra'; import { JsonValue } from '@backstage/types'; +import fs from 'fs-extra'; +import { Isolate } from 'isolated-vm'; import { getMajorNodeVersion, isNoNodeSnapshotOptionProvided } from './helpers'; // language=JavaScript @@ -215,6 +215,13 @@ export class SecureTemplater { return context.evalSync(`render(templateStr, templateValues)`); }; - return render; + + return { + render, + dispose: () => { + context.release(); + isolate.dispose(); + }, + }; } } diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/fetch/templateActionHandler.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/fetch/templateActionHandler.ts index e436616651..9ee580e324 100644 --- a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/fetch/templateActionHandler.ts +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/fetch/templateActionHandler.ts @@ -27,10 +27,10 @@ import { import fs from 'fs-extra'; import globby from 'globby'; import { isBinaryFile } from 'isbinaryfile'; -import { createDefaultFilters } from '../../../../lib/templating/filters/createDefaultFilters'; -import { convertFiltersToRecord } from '../../../../util/templating'; -import { SecureTemplater } from '../../../../lib/templating/SecureTemplater'; import { extname } from 'path'; +import { createDefaultFilters } from '../../../../lib/templating/filters/createDefaultFilters'; +import { SecureTemplater } from '../../../../lib/templating/SecureTemplater'; +import { convertFiltersToRecord } from '../../../../util/templating'; export type TemplateActionInput = { targetPath?: string; @@ -107,15 +107,16 @@ export async function createTemplateActionHandler< ctx.input.values, ); - const renderTemplate = await SecureTemplater.loadRenderer({ - cookiecutterCompat: ctx.input.cookiecutterCompat, - templateFilters, - templateGlobals, - nunjucksConfigs: { - trimBlocks: ctx.input.trimBlocks, - lstripBlocks: ctx.input.lstripBlocks, - }, - }); + const { render: renderTemplate, dispose } = + await SecureTemplater.loadRenderer({ + cookiecutterCompat: ctx.input.cookiecutterCompat, + templateFilters, + templateGlobals, + nunjucksConfigs: { + trimBlocks: ctx.input.trimBlocks, + lstripBlocks: ctx.input.lstripBlocks, + }, + }); for (const location of allEntriesInTemplate) { let renderContents: boolean; @@ -186,6 +187,7 @@ export async function createTemplateActionHandler< } } } + dispose(); ctx.logger.info(`Template result written to ${outputDir}`); } diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/fetch/templateFileActionHandler.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/fetch/templateFileActionHandler.ts index d82a1dd5a2..8e5e8d14e9 100644 --- a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/fetch/templateFileActionHandler.ts +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/fetch/templateFileActionHandler.ts @@ -13,6 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ +import { resolveSafeChildPath } from '@backstage/backend-plugin-api'; import { ScmIntegrations } from '@backstage/integration'; import { ActionContext, @@ -20,11 +21,10 @@ import { TemplateGlobal, } from '@backstage/plugin-scaffolder-node'; import fs from 'fs-extra'; -import { createDefaultFilters } from '../../../../lib/templating/filters/createDefaultFilters'; -import { convertFiltersToRecord } from '../../../../util/templating'; -import { resolveSafeChildPath } from '@backstage/backend-plugin-api'; import path from 'path'; +import { createDefaultFilters } from '../../../../lib/templating/filters/createDefaultFilters'; import { SecureTemplater } from '../../../../lib/templating/SecureTemplater'; +import { convertFiltersToRecord } from '../../../../util/templating'; export type TemplateFileActionInput = { targetPath: string; @@ -80,20 +80,22 @@ export async function createTemplateFileActionHandler< ctx.input.values, ); - const renderTemplate = await SecureTemplater.loadRenderer({ - cookiecutterCompat, - templateFilters, - templateGlobals, - nunjucksConfigs: { - trimBlocks: ctx.input.trimBlocks, - lstripBlocks: ctx.input.lstripBlocks, - }, - }); + const { render: renderTemplate, dispose } = + await SecureTemplater.loadRenderer({ + cookiecutterCompat, + templateFilters, + templateGlobals, + nunjucksConfigs: { + trimBlocks: ctx.input.trimBlocks, + lstripBlocks: ctx.input.lstripBlocks, + }, + }); const contents = await fs.readFile(filePath, 'utf-8'); const result = renderTemplate(contents, context); await fs.ensureDir(path.dirname(outputPath)); await fs.outputFile(outputPath, result); + dispose(); ctx.logger.info(`Template file has been written to ${outputPath}`); } diff --git a/plugins/scaffolder-backend/src/scaffolder/tasks/NunjucksWorkflowRunner.ts b/plugins/scaffolder-backend/src/scaffolder/tasks/NunjucksWorkflowRunner.ts index e2e36438a1..a9a3a31121 100644 --- a/plugins/scaffolder-backend/src/scaffolder/tasks/NunjucksWorkflowRunner.ts +++ b/plugins/scaffolder-backend/src/scaffolder/tasks/NunjucksWorkflowRunner.ts @@ -55,15 +55,15 @@ import { TemplateFilter, TemplateGlobal, } from '@backstage/plugin-scaffolder-node'; -import { createDefaultFilters } from '../../lib/templating/filters/createDefaultFilters'; -import { scaffolderActionRules } from '../../service/rules'; -import { createCounterMetric, createHistogramMetric } from '../../util/metrics'; -import { BackstageLoggerTransport, WinstonLogger } from './logger'; -import { convertFiltersToRecord } from '../../util/templating'; import { CheckpointContext, CheckpointState, } from '@backstage/plugin-scaffolder-node/alpha'; +import { createDefaultFilters } from '../../lib/templating/filters/createDefaultFilters'; +import { scaffolderActionRules } from '../../service/rules'; +import { createCounterMetric, createHistogramMetric } from '../../util/metrics'; +import { convertFiltersToRecord } from '../../util/templating'; +import { BackstageLoggerTransport, WinstonLogger } from './logger'; type NunjucksWorkflowRunnerOptions = { workingDirectory: string; @@ -485,13 +485,14 @@ export class NunjucksWorkflowRunner implements WorkflowRunner { const { additionalTemplateFilters, additionalTemplateGlobals } = this.options; - const renderTemplate = await SecureTemplater.loadRenderer({ - templateFilters: { - ...this.defaultTemplateFilters, - ...additionalTemplateFilters, - }, - templateGlobals: additionalTemplateGlobals, - }); + const { render: renderTemplate, dispose } = + await SecureTemplater.loadRenderer({ + templateFilters: { + ...this.defaultTemplateFilters, + ...additionalTemplateFilters, + }, + templateGlobals: additionalTemplateGlobals, + }); try { await task.rehydrateWorkspace?.({ taskId, targetPath: workspacePath }); @@ -533,6 +534,7 @@ export class NunjucksWorkflowRunner implements WorkflowRunner { const output = this.render(task.spec.output, context, renderTemplate); await taskTrack.markSuccessful(); await task.cleanWorkspace?.(); + dispose(); return { output }; } finally { From c78b3b68f6f90ddef4e13f8ba818f9bd1d6543b2 Mon Sep 17 00:00:00 2001 From: Justin Bryant Date: Fri, 17 Apr 2026 17:10:28 -0400 Subject: [PATCH 041/434] chore: add changeset Signed-off-by: Justin Bryant --- .changeset/bitter-files-flash.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/bitter-files-flash.md diff --git a/.changeset/bitter-files-flash.md b/.changeset/bitter-files-flash.md new file mode 100644 index 0000000000..1e0985c39c --- /dev/null +++ b/.changeset/bitter-files-flash.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-scaffolder-backend': minor +--- + +Add explicit memory management to SecureTemplater usage From b7da1160fb33f6d5a3d7ccee27091c864bcb089c Mon Sep 17 00:00:00 2001 From: Justin Bryant Date: Fri, 17 Apr 2026 17:15:50 -0400 Subject: [PATCH 042/434] Update plugins/scaffolder-backend/src/scaffolder/tasks/NunjucksWorkflowRunner.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Signed-off-by: Justin Bryant --- .../src/scaffolder/tasks/NunjucksWorkflowRunner.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/plugins/scaffolder-backend/src/scaffolder/tasks/NunjucksWorkflowRunner.ts b/plugins/scaffolder-backend/src/scaffolder/tasks/NunjucksWorkflowRunner.ts index a9a3a31121..51e41c7add 100644 --- a/plugins/scaffolder-backend/src/scaffolder/tasks/NunjucksWorkflowRunner.ts +++ b/plugins/scaffolder-backend/src/scaffolder/tasks/NunjucksWorkflowRunner.ts @@ -534,10 +534,14 @@ export class NunjucksWorkflowRunner implements WorkflowRunner { const output = this.render(task.spec.output, context, renderTemplate); await taskTrack.markSuccessful(); await task.cleanWorkspace?.(); - dispose(); return { output }; } finally { + try { + dispose(); + } catch { + // Ignore disposal errors so they don't mask the original failure. + } if (workspacePath) { await fs.remove(workspacePath); } From f063176143a2999b3c4dbdce443ef368d081f582 Mon Sep 17 00:00:00 2001 From: Justin Bryant Date: Fri, 17 Apr 2026 17:17:12 -0400 Subject: [PATCH 043/434] Update plugins/scaffolder-backend/src/scaffolder/actions/builtin/fetch/templateFileActionHandler.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Signed-off-by: Justin Bryant --- .../builtin/fetch/templateFileActionHandler.ts | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/fetch/templateFileActionHandler.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/fetch/templateFileActionHandler.ts index 8e5e8d14e9..f5663ff220 100644 --- a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/fetch/templateFileActionHandler.ts +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/fetch/templateFileActionHandler.ts @@ -91,11 +91,14 @@ export async function createTemplateFileActionHandler< }, }); - const contents = await fs.readFile(filePath, 'utf-8'); - const result = renderTemplate(contents, context); - await fs.ensureDir(path.dirname(outputPath)); - await fs.outputFile(outputPath, result); + try { + const contents = await fs.readFile(filePath, 'utf-8'); + const result = renderTemplate(contents, context); + await fs.ensureDir(path.dirname(outputPath)); + await fs.outputFile(outputPath, result); - dispose(); - ctx.logger.info(`Template file has been written to ${outputPath}`); + ctx.logger.info(`Template file has been written to ${outputPath}`); + } finally { + dispose(); + } } From 6a73fff696b013b18be8570c56944148bb624591 Mon Sep 17 00:00:00 2001 From: Justin Bryant Date: Fri, 17 Apr 2026 17:25:50 -0400 Subject: [PATCH 044/434] fix(SecureTemplater): implement PR suggestions Signed-off-by: Justin Bryant --- .changeset/bitter-files-flash.md | 2 +- .../src/lib/templating/SecureTemplater.ts | 12 +- .../builtin/fetch/templateActionHandler.ts | 122 +++++++++--------- 3 files changed, 73 insertions(+), 63 deletions(-) diff --git a/.changeset/bitter-files-flash.md b/.changeset/bitter-files-flash.md index 1e0985c39c..9b7ab014a5 100644 --- a/.changeset/bitter-files-flash.md +++ b/.changeset/bitter-files-flash.md @@ -1,5 +1,5 @@ --- -'@backstage/plugin-scaffolder-backend': minor +'@backstage/plugin-scaffolder-backend': major --- Add explicit memory management to SecureTemplater usage diff --git a/plugins/scaffolder-backend/src/lib/templating/SecureTemplater.ts b/plugins/scaffolder-backend/src/lib/templating/SecureTemplater.ts index 71a7a14e60..e230dba184 100644 --- a/plugins/scaffolder-backend/src/lib/templating/SecureTemplater.ts +++ b/plugins/scaffolder-backend/src/lib/templating/SecureTemplater.ts @@ -202,8 +202,10 @@ export class SecureTemplater { await nunjucksScript.run(context); const render: SecureTemplateRenderer = (template, values) => { - if (!context) { - throw new Error('SecureTemplater has not been initialized'); + if (!context || isolate.isDisposed) { + throw new Error( + 'SecureTemplater has not been initialized or has been disposed', + ); } contextGlobal.setSync('templateStr', String(template)); @@ -219,8 +221,10 @@ export class SecureTemplater { return { render, dispose: () => { - context.release(); - isolate.dispose(); + if (context && !isolate.isDisposed) { + context.release(); + isolate.dispose(); + } }, }; } diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/fetch/templateActionHandler.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/fetch/templateActionHandler.ts index 9ee580e324..a8a4dbafd7 100644 --- a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/fetch/templateActionHandler.ts +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/fetch/templateActionHandler.ts @@ -117,77 +117,83 @@ export async function createTemplateActionHandler< lstripBlocks: ctx.input.lstripBlocks, }, }); + try { + for (const location of allEntriesInTemplate) { + let renderContents: boolean; - for (const location of allEntriesInTemplate) { - let renderContents: boolean; - - let localOutputPath = location; - if (extension) { - renderContents = extname(localOutputPath) === extension; - if (renderContents) { - localOutputPath = localOutputPath.slice(0, -extension.length); - } - // extension is mutual exclusive with copyWithoutRender/copyWithoutTemplating, - // therefore the output path is always rendered. - localOutputPath = renderTemplate(localOutputPath, context); - } else { - renderContents = !nonTemplatedEntries.has(location); - // The logic here is a bit tangled because it depends on two variables. - // If renderFilename is true, which means copyWithoutTemplating is used, - // then the path is always rendered. - // If renderFilename is false, which means copyWithoutRender is used, - // then matched file/directory won't be processed, same as before. - if (renderFilename) { + let localOutputPath = location; + if (extension) { + renderContents = extname(localOutputPath) === extension; + if (renderContents) { + localOutputPath = localOutputPath.slice(0, -extension.length); + } + // extension is mutual exclusive with copyWithoutRender/copyWithoutTemplating, + // therefore the output path is always rendered. localOutputPath = renderTemplate(localOutputPath, context); } else { - localOutputPath = renderContents - ? renderTemplate(localOutputPath, context) - : localOutputPath; + renderContents = !nonTemplatedEntries.has(location); + // The logic here is a bit tangled because it depends on two variables. + // If renderFilename is true, which means copyWithoutTemplating is used, + // then the path is always rendered. + // If renderFilename is false, which means copyWithoutRender is used, + // then matched file/directory won't be processed, same as before. + if (renderFilename) { + localOutputPath = renderTemplate(localOutputPath, context); + } else { + localOutputPath = renderContents + ? renderTemplate(localOutputPath, context) + : localOutputPath; + } } - } - if (containsSkippedContent(localOutputPath)) { - continue; - } + if (containsSkippedContent(localOutputPath)) { + continue; + } - const outputPath = resolveSafeChildPath(outputDir, localOutputPath); - if (fs.existsSync(outputPath) && !ctx.input.replace) { - continue; - } + const outputPath = resolveSafeChildPath(outputDir, localOutputPath); + if (fs.existsSync(outputPath) && !ctx.input.replace) { + continue; + } - if (!renderContents && !extension) { - ctx.logger.info(`Copying file/directory ${location} without processing.`); - } - - if (location.endsWith('/')) { - ctx.logger.info(`Writing directory ${location} to template output path.`); - await fs.ensureDir(outputPath); - } else { - const inputFilePath = resolveSafeChildPath(templateDir, location); - const stats = await fs.promises.lstat(inputFilePath); - - if (stats.isSymbolicLink() || (await isBinaryFile(inputFilePath))) { + if (!renderContents && !extension) { ctx.logger.info( - `Copying file binary or symbolic link at ${location}, to template output path.`, + `Copying file/directory ${location} without processing.`, ); - await fs.copy(inputFilePath, outputPath); + } + + if (location.endsWith('/')) { + ctx.logger.info( + `Writing directory ${location} to template output path.`, + ); + await fs.ensureDir(outputPath); } else { - const statsObj = await fs.stat(inputFilePath); - ctx.logger.info( - `Writing file ${location} to template output path with mode ${statsObj.mode}.`, - ); - const inputFileContents = await fs.readFile(inputFilePath, 'utf-8'); - await fs.outputFile( - outputPath, - renderContents - ? renderTemplate(inputFileContents, context) - : inputFileContents, - { mode: statsObj.mode }, - ); + const inputFilePath = resolveSafeChildPath(templateDir, location); + const stats = await fs.promises.lstat(inputFilePath); + + if (stats.isSymbolicLink() || (await isBinaryFile(inputFilePath))) { + ctx.logger.info( + `Copying file binary or symbolic link at ${location}, to template output path.`, + ); + await fs.copy(inputFilePath, outputPath); + } else { + const statsObj = await fs.stat(inputFilePath); + ctx.logger.info( + `Writing file ${location} to template output path with mode ${statsObj.mode}.`, + ); + const inputFileContents = await fs.readFile(inputFilePath, 'utf-8'); + await fs.outputFile( + outputPath, + renderContents + ? renderTemplate(inputFileContents, context) + : inputFileContents, + { mode: statsObj.mode }, + ); + } } } + } finally { + dispose(); } - dispose(); ctx.logger.info(`Template result written to ${outputDir}`); } From 191ea1357847bf6145e17d94f98ca2a26f2d5e50 Mon Sep 17 00:00:00 2001 From: Justin Bryant Date: Fri, 17 Apr 2026 17:43:07 -0400 Subject: [PATCH 045/434] Update plugins/scaffolder-backend/src/lib/templating/SecureTemplater.test.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Signed-off-by: Justin Bryant --- .../src/lib/templating/SecureTemplater.test.ts | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/plugins/scaffolder-backend/src/lib/templating/SecureTemplater.test.ts b/plugins/scaffolder-backend/src/lib/templating/SecureTemplater.test.ts index 2957848279..cd9eedcd96 100644 --- a/plugins/scaffolder-backend/src/lib/templating/SecureTemplater.test.ts +++ b/plugins/scaffolder-backend/src/lib/templating/SecureTemplater.test.ts @@ -36,9 +36,21 @@ describe('SecureTemplater', () => { test: 'my-value', }), ).toThrow(/expected name as lookup value, got ./); + dispose(); + + expect(() => render('${{ test }}', { test: 'my-value' })).toThrow( + /disposed/i, + ); }); + it('should allow dispose to be called more than once', async () => { + const { dispose } = await SecureTemplater.loadRenderer(); + + dispose(); + + expect(() => dispose()).not.toThrow(); + }); it('should make cookiecutter compatibility available when requested', async () => { const { render: renderWith, dispose: disposeWith } = await SecureTemplater.loadRenderer({ From 6d6c7f64a6ae23d5c4d1fd83deca6988f0f5d89d Mon Sep 17 00:00:00 2001 From: Justin Bryant Date: Fri, 17 Apr 2026 17:43:25 -0400 Subject: [PATCH 046/434] Update plugins/scaffolder-backend/src/scaffolder/tasks/NunjucksWorkflowRunner.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Signed-off-by: Justin Bryant --- .../src/scaffolder/tasks/NunjucksWorkflowRunner.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/plugins/scaffolder-backend/src/scaffolder/tasks/NunjucksWorkflowRunner.ts b/plugins/scaffolder-backend/src/scaffolder/tasks/NunjucksWorkflowRunner.ts index 57c829a499..a002d8030f 100644 --- a/plugins/scaffolder-backend/src/scaffolder/tasks/NunjucksWorkflowRunner.ts +++ b/plugins/scaffolder-backend/src/scaffolder/tasks/NunjucksWorkflowRunner.ts @@ -365,8 +365,7 @@ export class NunjucksWorkflowRunner implements WorkflowRunner { )) ?? {}; taskLogger.info( - `Running ${action.id - } in dry-run mode with inputs (secrets redacted): ${JSON.stringify( + `Running ${action.id} in dry-run mode with inputs (secrets redacted): ${JSON.stringify( debugInput, undefined, 2, From f32100a977ede18ec713cb95e2dfbd55f784815d Mon Sep 17 00:00:00 2001 From: Justin Bryant Date: Fri, 17 Apr 2026 17:43:38 -0400 Subject: [PATCH 047/434] Update plugins/scaffolder-backend/src/scaffolder/tasks/NunjucksWorkflowRunner.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Signed-off-by: Justin Bryant --- .../src/scaffolder/tasks/NunjucksWorkflowRunner.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/plugins/scaffolder-backend/src/scaffolder/tasks/NunjucksWorkflowRunner.ts b/plugins/scaffolder-backend/src/scaffolder/tasks/NunjucksWorkflowRunner.ts index a002d8030f..28667ed9f2 100644 --- a/plugins/scaffolder-backend/src/scaffolder/tasks/NunjucksWorkflowRunner.ts +++ b/plugins/scaffolder-backend/src/scaffolder/tasks/NunjucksWorkflowRunner.ts @@ -435,8 +435,7 @@ export class NunjucksWorkflowRunner implements WorkflowRunner { continue; } - const actionId = `${action.id}${iteration.each ? `[${iteration.each.key}]` : '' - }`; + const actionId = `${action.id}${iteration.each ? `[${iteration.each.key}]` : ''}`; if (action.schema?.input) { const validateResult = validateJsonSchema( From ac64f0ad049dbadfcf0c61a1528312923aeecae1 Mon Sep 17 00:00:00 2001 From: Justin Bryant Date: Fri, 17 Apr 2026 17:44:35 -0400 Subject: [PATCH 048/434] fix(SecureTemplater): implement PR suggestions Signed-off-by: Justin Bryant --- .../src/lib/templating/SecureTemplater.ts | 8 ++++++-- .../actions/builtin/fetch/templateFileActionHandler.ts | 4 +--- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/plugins/scaffolder-backend/src/lib/templating/SecureTemplater.ts b/plugins/scaffolder-backend/src/lib/templating/SecureTemplater.ts index e230dba184..72815fbd89 100644 --- a/plugins/scaffolder-backend/src/lib/templating/SecureTemplater.ts +++ b/plugins/scaffolder-backend/src/lib/templating/SecureTemplater.ts @@ -222,8 +222,12 @@ export class SecureTemplater { render, dispose: () => { if (context && !isolate.isDisposed) { - context.release(); - isolate.dispose(); + try { + context.release(); + isolate.dispose(); + } catch (error) { + // Ignore errors during dispose, as there's not much we can do about it + } } }, }; diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/fetch/templateFileActionHandler.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/fetch/templateFileActionHandler.ts index ceaeb9b984..ab85070fa3 100644 --- a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/fetch/templateFileActionHandler.ts +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/fetch/templateFileActionHandler.ts @@ -21,10 +21,8 @@ import { TemplateGlobal, } from '@backstage/plugin-scaffolder-node'; import fs from 'fs-extra'; -import { createDefaultFilters } from '../../../../lib/templating/filters/createDefaultFilters'; -import { convertFiltersToRecord } from '../../../../util/templating'; -import { resolveSafeChildPath } from '@backstage/backend-plugin-api'; import path from 'node:path'; +import { createDefaultFilters } from '../../../../lib/templating/filters/createDefaultFilters'; import { SecureTemplater } from '../../../../lib/templating/SecureTemplater'; import { convertFiltersToRecord } from '../../../../util/templating'; From 80c5481a443499c62969afe581bcaad07fdc3953 Mon Sep 17 00:00:00 2001 From: Justin Bryant Date: Fri, 17 Apr 2026 18:02:53 -0400 Subject: [PATCH 049/434] fix: run prettier Signed-off-by: Justin Bryant --- .../tasks/NunjucksWorkflowRunner.ts | 23 +++++++++++++------ 1 file changed, 16 insertions(+), 7 deletions(-) diff --git a/plugins/scaffolder-backend/src/scaffolder/tasks/NunjucksWorkflowRunner.ts b/plugins/scaffolder-backend/src/scaffolder/tasks/NunjucksWorkflowRunner.ts index 28667ed9f2..0d900326c8 100644 --- a/plugins/scaffolder-backend/src/scaffolder/tasks/NunjucksWorkflowRunner.ts +++ b/plugins/scaffolder-backend/src/scaffolder/tasks/NunjucksWorkflowRunner.ts @@ -66,6 +66,11 @@ import { CheckpointState, } from '@backstage/plugin-scaffolder-node/alpha'; import { resolveDefaultEnvironment } from '../../lib/defaultEnvironment'; +import { createDefaultFilters } from '../../lib/templating/filters/createDefaultFilters'; +import { scaffolderActionRules } from '../../service/rules'; +import { createCounterMetric, createHistogramMetric } from '../../util/metrics'; +import { convertFiltersToRecord } from '../../util/templating'; +import { BackstageLoggerTransport, WinstonLogger } from './logger'; type NunjucksWorkflowRunnerOptions = { workingDirectory: string; @@ -365,7 +370,9 @@ export class NunjucksWorkflowRunner implements WorkflowRunner { )) ?? {}; taskLogger.info( - `Running ${action.id} in dry-run mode with inputs (secrets redacted): ${JSON.stringify( + `Running ${ + action.id + } in dry-run mode with inputs (secrets redacted): ${JSON.stringify( debugInput, undefined, 2, @@ -409,8 +416,8 @@ export class NunjucksWorkflowRunner implements WorkflowRunner { const iterations = ( resolvedEach ? Object.entries(resolvedEach).map(([key, value]) => ({ - each: { key, value }, - })) + each: { key, value }, + })) : [{}] ).map(i => { const fullContext = { ...preIterationContext, ...i }; @@ -435,7 +442,9 @@ export class NunjucksWorkflowRunner implements WorkflowRunner { continue; } - const actionId = `${action.id}${iteration.each ? `[${iteration.each.key}]` : ''}`; + const actionId = `${action.id}${ + iteration.each ? `[${iteration.each.key}]` : '' + }`; if (action.schema?.input) { const validateResult = validateJsonSchema( @@ -668,9 +677,9 @@ export class NunjucksWorkflowRunner implements WorkflowRunner { const [decision]: PolicyDecision[] = this.options.permissions && task.spec.steps.length ? await this.options.permissions.authorizeConditional( - [{ permission: actionExecutePermission }], - { credentials: await task.getInitiatorCredentials() }, - ) + [{ permission: actionExecutePermission }], + { credentials: await task.getInitiatorCredentials() }, + ) : [{ result: AuthorizeResult.ALLOW }]; for (const step of task.spec.steps) { From c96e2b3445a06b7a7da7fba0d0e8f52fde837943 Mon Sep 17 00:00:00 2001 From: Charles de Dreuille Date: Sun, 19 Apr 2026 09:48:56 +0200 Subject: [PATCH 050/434] feat(ui): add description, tags, and metadata props to Header MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add three new props to the Header component: - `tags`: renders a row of text/link items above the title, separated by 3×3px circle dividers - `description`: renders a markdown string below the title with inline link support via react-markdown - `metadata`: renders key-value pairs below the description with 20px gaps Also deprecates the `breadcrumbs` prop for future removal. Signed-off-by: Charles de Dreuille Made-with: Cursor --- .changeset/header-improvements.md | 7 ++ packages/ui/package.json | 1 + .../src/components/Header/Header.module.css | 24 +++++++ .../src/components/Header/Header.stories.tsx | 64 +++++++++++++++++++ packages/ui/src/components/Header/Header.tsx | 62 +++++++++++++++++- .../ui/src/components/Header/definition.ts | 7 ++ packages/ui/src/components/Header/types.ts | 30 +++++++++ 7 files changed, 193 insertions(+), 2 deletions(-) create mode 100644 .changeset/header-improvements.md diff --git a/.changeset/header-improvements.md b/.changeset/header-improvements.md new file mode 100644 index 0000000000..961cb3c0c7 --- /dev/null +++ b/.changeset/header-improvements.md @@ -0,0 +1,7 @@ +--- +'@backstage/ui': patch +--- + +Added `description`, `tags`, and `metadata` props to the `Header` component. The `description` prop accepts a markdown string with support for inline links. The `tags` prop renders a row of text or link items above the title. The `metadata` prop renders key-value pairs below the description. The `breadcrumbs` prop has been deprecated and will be removed in a future release. + +**Affected components:** Header diff --git a/packages/ui/package.json b/packages/ui/package.json index 1b17bc8744..242d15036c 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -52,6 +52,7 @@ "clsx": "^2.1.1", "react-aria": "~3.48.0", "react-aria-components": "~1.17.0", + "react-markdown": "^8.0.0", "react-stately": "~3.46.0", "use-sync-external-store": "^1.4.0" }, diff --git a/packages/ui/src/components/Header/Header.module.css b/packages/ui/src/components/Header/Header.module.css index d74d3eeb50..c386d5a00e 100644 --- a/packages/ui/src/components/Header/Header.module.css +++ b/packages/ui/src/components/Header/Header.module.css @@ -47,4 +47,28 @@ align-items: center; gap: var(--bui-space-2); } + + .bui-HeaderTags { + display: flex; + flex-direction: row; + align-items: center; + gap: var(--bui-space-2); + flex-wrap: wrap; + } + + .bui-HeaderTagDivider { + width: 3px; + height: 3px; + border-radius: 50%; + background-color: var(--bui-fg-secondary); + flex-shrink: 0; + } + + .bui-HeaderMetaRow { + display: flex; + flex-direction: row; + align-items: center; + gap: 20px; + flex-wrap: wrap; + } } diff --git a/packages/ui/src/components/Header/Header.stories.tsx b/packages/ui/src/components/Header/Header.stories.tsx index c30534a993..0cfe6c03bf 100644 --- a/packages/ui/src/components/Header/Header.stories.tsx +++ b/packages/ui/src/components/Header/Header.stories.tsx @@ -152,6 +152,58 @@ export const WithLongBreadcrumbs = meta.story({ }, }); +export const WithDescription = meta.story({ + decorators: [withRouter], + args: { + ...Default.input.args, + description: + 'This is a description of the page. It can include [inline links](https://backstage.io) and **bold text**.', + }, +}); + +export const WithTags = meta.story({ + decorators: [withRouter], + args: { + ...Default.input.args, + tags: [ + { label: 'TypeScript' }, + { label: 'Platform', href: '/platform' }, + { label: 'Gold' }, + ], + }, +}); + +export const WithMetadata = meta.story({ + decorators: [withRouter], + args: { + ...Default.input.args, + metadata: [ + { label: 'Owner', value: 'platform-team' }, + { label: 'Type', value: 'website' }, + { label: 'Tier', value: 'gold' }, + ], + }, +}); + +export const WithDescriptionTagsAndMetadata = meta.story({ + decorators: [withRouter], + args: { + ...Default.input.args, + description: + 'This is a description of the page. It can include [inline links](https://backstage.io) and **bold text**.', + tags: [ + { label: 'TypeScript' }, + { label: 'Platform', href: '/platform' }, + { label: 'Gold' }, + ], + metadata: [ + { label: 'Owner', value: 'platform-team' }, + { label: 'Type', value: 'website' }, + { label: 'Tier', value: 'gold' }, + ], + }, +}); + export const WithEverything = meta.story({ decorators: [withRouter], args: { @@ -159,6 +211,18 @@ export const WithEverything = meta.story({ tabs, customActions: , breadcrumbs: [{ label: 'Home', href: '/' }], + description: + 'This is a description of the page. It can include [inline links](https://backstage.io) and **bold text**.', + tags: [ + { label: 'TypeScript' }, + { label: 'Platform', href: '/platform' }, + { label: 'Gold' }, + ], + metadata: [ + { label: 'Owner', value: 'platform-team' }, + { label: 'Type', value: 'website' }, + { label: 'Tier', value: 'gold' }, + ], }, }); diff --git a/packages/ui/src/components/Header/Header.tsx b/packages/ui/src/components/Header/Header.tsx index bc8289cdf1..868b084312 100644 --- a/packages/ui/src/components/Header/Header.tsx +++ b/packages/ui/src/components/Header/Header.tsx @@ -23,6 +23,7 @@ import { HeaderDefinition } from './definition'; import { Container } from '../Container'; import { Link } from '../Link'; import { Fragment } from 'react/jsx-runtime'; +import ReactMarkdown from 'react-markdown'; /** * A secondary header with title, breadcrumbs, tabs, and actions. @@ -31,11 +32,38 @@ import { Fragment } from 'react/jsx-runtime'; */ export const Header = (props: HeaderProps) => { const { ownProps } = useDefinition(HeaderDefinition, props); - const { classes, title, tabs, activeTabId, customActions, breadcrumbs } = - ownProps; + const { + classes, + title, + tabs, + activeTabId, + customActions, + breadcrumbs, + description, + tags, + metadata, + } = ownProps; return ( + {tags && tags.length > 0 && ( +
+ {tags.map((tag, i) => ( + + {i > 0 && } + {tag.href ? ( + + {tag.label} + + ) : ( + + {tag.label} + + )} + + ))} +
+ )}
{breadcrumbs && @@ -61,6 +89,36 @@ export const Header = (props: HeaderProps) => {
{customActions}
+ {description && ( + ( + + {children} + + ), + a: ({ href, children }) => ( + + {children} + + ), + }} + > + {description} + + )} + {metadata && metadata.length > 0 && ( +
+ {metadata.map(item => ( + + {item.label}: {item.value} + + ))} +
+ )} {tabs && (
diff --git a/packages/ui/src/components/Header/definition.ts b/packages/ui/src/components/Header/definition.ts index 2876770339..29e813974b 100644 --- a/packages/ui/src/components/Header/definition.ts +++ b/packages/ui/src/components/Header/definition.ts @@ -30,6 +30,10 @@ export const HeaderDefinition = defineComponent()({ breadcrumbs: 'bui-HeaderBreadcrumbs', tabsWrapper: 'bui-HeaderTabsWrapper', controls: 'bui-HeaderControls', + tags: 'bui-HeaderTags', + tagDivider: 'bui-HeaderTagDivider', + description: 'bui-HeaderDescription', + metaRow: 'bui-HeaderMetaRow', }, propDefs: { title: {}, @@ -37,6 +41,9 @@ export const HeaderDefinition = defineComponent()({ tabs: {}, activeTabId: {}, breadcrumbs: {}, + description: {}, + tags: {}, + metadata: {}, className: {}, }, }); diff --git a/packages/ui/src/components/Header/types.ts b/packages/ui/src/components/Header/types.ts index 1d4acc45ea..b2c687fcf5 100644 --- a/packages/ui/src/components/Header/types.ts +++ b/packages/ui/src/components/Header/types.ts @@ -52,6 +52,26 @@ export interface HeaderNavTabGroup { */ export type HeaderNavTabItem = HeaderNavTab | HeaderNavTabGroup; +/** + * Represents a tag item in the header. + * + * @public + */ +export interface HeaderTag { + label: string; + href?: string; +} + +/** + * Represents a metadata key-value pair in the header. + * + * @public + */ +export interface HeaderMetadataItem { + label: string; + value: React.ReactNode; +} + /** * Own props for the Header component. * @@ -62,7 +82,17 @@ export interface HeaderOwnProps { customActions?: React.ReactNode; tabs?: HeaderNavTabItem[]; activeTabId?: string | null; + /** + * @deprecated The breadcrumbs prop will be removed in a future release. + */ breadcrumbs?: HeaderBreadcrumb[]; + /** + * Markdown string rendered below the title. Only inline elements are + * supported (links, bold, italic). Block-level markdown is not rendered. + */ + description?: string; + tags?: HeaderTag[]; + metadata?: HeaderMetadataItem[]; className?: string; } From 33ea7fb3017d850044c2c6dc5482ac5de53a9721 Mon Sep 17 00:00:00 2001 From: Charles de Dreuille Date: Sun, 19 Apr 2026 09:54:19 +0200 Subject: [PATCH 051/434] docs(ui): update Header docs with tags, description, and metadata props Add new props to the Header API reference and examples on ui.backstage.io. Also adds deprecated badge support to the shared PropsTable component so the breadcrumbs prop is visually marked as deprecated. Signed-off-by: Charles de Dreuille Made-with: Cursor --- .../src/app/components/header/components.tsx | 36 +++++++++++++++ docs-ui/src/app/components/header/page.mdx | 32 +++++++++++-- .../components/header/props-definition.tsx | 45 +++++++++++++++++++ docs-ui/src/app/components/header/snippets.ts | 36 +++++++++++++-- docs-ui/src/components/Chip/Chip.tsx | 8 +++- docs-ui/src/components/Chip/styles.module.css | 10 +++++ .../src/components/PropsTable/PropsTable.tsx | 7 ++- docs-ui/src/utils/propDefs.ts | 1 + 8 files changed, 166 insertions(+), 9 deletions(-) diff --git a/docs-ui/src/app/components/header/components.tsx b/docs-ui/src/app/components/header/components.tsx index 26dc715a5d..4eb911e143 100644 --- a/docs-ui/src/app/components/header/components.tsx +++ b/docs-ui/src/app/components/header/components.tsx @@ -29,10 +29,25 @@ const breadcrumbs = [ }, ]; +const tags = [ + { label: 'TypeScript' }, + { label: 'Platform', href: '/platform' }, + { label: 'Gold' }, +]; + +const metadata = [ + { label: 'Owner', value: 'platform-team' }, + { label: 'Type', value: 'website' }, + { label: 'Tier', value: 'gold' }, +]; + export const WithEverything = () => (
( ); +export const WithTags = () => ( + +
+ +); + +export const WithDescription = () => ( + +
+ +); + +export const WithMetadata = () => ( + +
+ +); + export const WithLongBreadcrumbs = () => (
diff --git a/docs-ui/src/app/components/header/page.mdx b/docs-ui/src/app/components/header/page.mdx index 9e02b70a70..a38aac2864 100644 --- a/docs-ui/src/app/components/header/page.mdx +++ b/docs-ui/src/app/components/header/page.mdx @@ -5,6 +5,9 @@ import { WithEverything, WithLongBreadcrumbs, WithTabs, + WithTags, + WithDescription, + WithMetadata, WithCustomActions, WithMenu, } from './components'; @@ -13,6 +16,9 @@ import { usage, defaultSnippet, withTabs, + withTags, + withDescription, + withMetadata, withBreadcrumbs, withCustomActions, withMenu, @@ -24,7 +30,7 @@ import { ChangelogComponent } from '@/components/ChangelogComponent'; } code={defaultSnippet} /> @@ -39,11 +45,23 @@ import { ChangelogComponent } from '@/components/ChangelogComponent'; ## Examples -### Breadcrumbs +### Tags -Labels are truncated at 240px. +Tags are rendered above the title. Each tag with an `href` renders as a link; tags without `href` render as plain text. Tags are separated by a small circle divider. -} code={withBreadcrumbs} /> +} code={withTags} /> + +### Description + +The description accepts a markdown string. Only inline elements are supported — links, bold, and italic. + +} code={withDescription} /> + +### Metadata + +Key-value pairs displayed below the description. + +} code={withMetadata} /> ### Tabs @@ -61,6 +79,12 @@ Use `customActions` to add a dropdown menu. } code={withMenu} /> +### Breadcrumbs (deprecated) + +The `breadcrumbs` prop is deprecated and will be removed in a future release. Labels are truncated at 240px. + +} code={withBreadcrumbs} /> + diff --git a/docs-ui/src/app/components/header/props-definition.tsx b/docs-ui/src/app/components/header/props-definition.tsx index 0acc7d30ae..3106e0853b 100644 --- a/docs-ui/src/app/components/header/props-definition.tsx +++ b/docs-ui/src/app/components/header/props-definition.tsx @@ -5,6 +5,50 @@ export const headerPagePropDefs: Record = { type: 'string', description: 'Page heading displayed in the header.', }, + tags: { + type: 'complex', + description: + 'Items displayed above the title. Each tag renders as a link when href is provided, or as plain text otherwise. Tags are separated by a small circle divider.', + complexType: { + name: 'HeaderTag[]', + properties: { + label: { + type: 'string', + required: true, + description: 'Display text for the tag.', + }, + href: { + type: 'string', + required: false, + description: 'URL to navigate to when the tag is clicked.', + }, + }, + }, + }, + description: { + type: 'string', + description: + 'Markdown string rendered below the title. Only inline elements are supported: links, bold, and italic. Block-level markdown such as headings or lists is not rendered.', + }, + metadata: { + type: 'complex', + description: 'Key-value pairs displayed below the description.', + complexType: { + name: 'HeaderMetadataItem[]', + properties: { + label: { + type: 'string', + required: true, + description: 'The key label, displayed in bold.', + }, + value: { + type: 'ReactNode', + required: true, + description: 'The value to display alongside the label.', + }, + }, + }, + }, customActions: { type: 'enum', values: ['ReactNode'], @@ -49,6 +93,7 @@ export const headerPagePropDefs: Record = { }, breadcrumbs: { type: 'complex', + deprecated: true, description: 'Breadcrumb trail displayed above the title.', complexType: { name: 'HeaderBreadcrumb[]', diff --git a/docs-ui/src/app/components/header/snippets.ts b/docs-ui/src/app/components/header/snippets.ts index 8dfef0e69e..f0803ae7c7 100644 --- a/docs-ui/src/app/components/header/snippets.ts +++ b/docs-ui/src/app/components/header/snippets.ts @@ -4,9 +4,16 @@ export const usage = `import { Header } from '@backstage/ui'; export const defaultSnippet = `
} />`; + +export const withTags = `
`; + +export const withDescription = `
`; + +export const withMetadata = `
`; diff --git a/docs-ui/src/components/Chip/Chip.tsx b/docs-ui/src/components/Chip/Chip.tsx index b348b46c32..a1d9d75e62 100644 --- a/docs-ui/src/components/Chip/Chip.tsx +++ b/docs-ui/src/components/Chip/Chip.tsx @@ -4,12 +4,18 @@ import styles from './styles.module.css'; export const Chip = ({ children, head = false, + deprecated = false, }: { children: ReactNode; head?: boolean; + deprecated?: boolean; }) => { return ( - + {children} ); diff --git a/docs-ui/src/components/Chip/styles.module.css b/docs-ui/src/components/Chip/styles.module.css index d752ae2602..2446e46388 100644 --- a/docs-ui/src/components/Chip/styles.module.css +++ b/docs-ui/src/components/Chip/styles.module.css @@ -14,6 +14,11 @@ color: #2563eb; } +.deprecated { + background-color: #fff4e5; + color: #b45309; +} + [data-theme-mode='dark'] .chip { background-color: #2c2c2c; color: #fff; @@ -22,3 +27,8 @@ [data-theme-mode='dark'] .chip.head { background-color: #33405b; } + +[data-theme-mode='dark'] .chip.deprecated { + background-color: #3d2a10; + color: #fbbf24; +} diff --git a/docs-ui/src/components/PropsTable/PropsTable.tsx b/docs-ui/src/components/PropsTable/PropsTable.tsx index a8af2ad116..db5759d0e3 100644 --- a/docs-ui/src/components/PropsTable/PropsTable.tsx +++ b/docs-ui/src/components/PropsTable/PropsTable.tsx @@ -52,7 +52,12 @@ export const PropsTable = >({ switch (column) { case 'prop': - return {propName}; + return ( +
+ {propName} + {propData.deprecated && deprecated} +
+ ); case 'type': return ( diff --git a/docs-ui/src/utils/propDefs.ts b/docs-ui/src/utils/propDefs.ts index f714dd0d08..f92f1a37eb 100644 --- a/docs-ui/src/utils/propDefs.ts +++ b/docs-ui/src/utils/propDefs.ts @@ -44,6 +44,7 @@ export type PropDef = { required?: boolean; responsive?: boolean; description?: ReactNode; + deprecated?: boolean; }; export { breakpoints }; From 2b97fc41bbe454b4cf07592e7f890eb41465a520 Mon Sep 17 00:00:00 2001 From: Charles de Dreuille Date: Sun, 19 Apr 2026 10:04:35 +0200 Subject: [PATCH 052/434] docs(ui): simplify Header main example by removing metadata Signed-off-by: Charles de Dreuille Made-with: Cursor --- docs-ui/src/app/components/header/components.tsx | 1 - docs-ui/src/app/components/header/snippets.ts | 5 ----- 2 files changed, 6 deletions(-) diff --git a/docs-ui/src/app/components/header/components.tsx b/docs-ui/src/app/components/header/components.tsx index 4eb911e143..1a34570c0b 100644 --- a/docs-ui/src/app/components/header/components.tsx +++ b/docs-ui/src/app/components/header/components.tsx @@ -47,7 +47,6 @@ export const WithEverything = () => ( title="Page Title" tags={tags} description="A short description of this page. Supports [inline links](https://backstage.io) and **bold text**." - metadata={metadata} tabs={tabs.slice(0, 2)} breadcrumbs={breadcrumbs.slice(0, 2)} customActions={ diff --git a/docs-ui/src/app/components/header/snippets.ts b/docs-ui/src/app/components/header/snippets.ts index f0803ae7c7..d5ded94872 100644 --- a/docs-ui/src/app/components/header/snippets.ts +++ b/docs-ui/src/app/components/header/snippets.ts @@ -10,11 +10,6 @@ export const defaultSnippet = `
Date: Sun, 19 Apr 2026 10:05:31 +0200 Subject: [PATCH 053/434] fix(ui): increase Header gap from space-1 to space-4 Signed-off-by: Charles de Dreuille Made-with: Cursor --- packages/ui/src/components/Header/Header.module.css | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/ui/src/components/Header/Header.module.css b/packages/ui/src/components/Header/Header.module.css index c386d5a00e..f46680cc21 100644 --- a/packages/ui/src/components/Header/Header.module.css +++ b/packages/ui/src/components/Header/Header.module.css @@ -20,7 +20,7 @@ .bui-Header { display: flex; flex-direction: column; - gap: var(--bui-space-1); + gap: var(--bui-space-4); margin-top: var(--bui-space-6); } From fcc8c4d328ccdce1d9ce90b25a40b0d527c9309f Mon Sep 17 00:00:00 2001 From: Charles de Dreuille Date: Sun, 19 Apr 2026 10:05:43 +0200 Subject: [PATCH 054/434] fix(ui): set Header gap to space-3 Signed-off-by: Charles de Dreuille Made-with: Cursor --- packages/ui/src/components/Header/Header.module.css | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/ui/src/components/Header/Header.module.css b/packages/ui/src/components/Header/Header.module.css index f46680cc21..2ab1eb0e8f 100644 --- a/packages/ui/src/components/Header/Header.module.css +++ b/packages/ui/src/components/Header/Header.module.css @@ -20,7 +20,7 @@ .bui-Header { display: flex; flex-direction: column; - gap: var(--bui-space-4); + gap: var(--bui-space-3); margin-top: var(--bui-space-6); } From 0e8edce069ffaf1bac8bd2e8681ca4f12ec2ceca Mon Sep 17 00:00:00 2001 From: Charles de Dreuille Date: Sun, 19 Apr 2026 10:48:09 +0200 Subject: [PATCH 055/434] feat(ui): add HeaderMetadataUsers component and polish Header metadata/tags - Add HeaderMetadataUsers component: single user shows avatar + name, multiple users show avatar stack with tooltip on hover - Use Pressable from react-aria for tooltip trigger compatibility - Switch tags and metadata text to body-medium variant - Fix metadata item styling: secondary color label, no bold, no colon, flex row with gap-2 between label and value - Update Header gap to space-3 - Update docs with HeaderMetadataUsers example, correct prop types, and synced default snippet Signed-off-by: Charles de Dreuille Made-with: Cursor --- .../src/app/components/header/components.tsx | 42 +++++- docs-ui/src/app/components/header/page.mdx | 16 +-- .../components/header/props-definition.tsx | 7 +- docs-ui/src/app/components/header/snippets.ts | 49 ++++++- .../src/components/Header/Header.module.css | 7 + .../src/components/Header/Header.stories.tsx | 124 +++++++++++++----- packages/ui/src/components/Header/Header.tsx | 13 +- .../Header/HeaderMetadataUsers.module.css | 33 +++++ .../components/Header/HeaderMetadataUsers.tsx | 70 ++++++++++ .../ui/src/components/Header/definition.ts | 1 + packages/ui/src/components/Header/index.tsx | 4 + packages/ui/src/components/Header/types.ts | 10 ++ 12 files changed, 319 insertions(+), 57 deletions(-) create mode 100644 packages/ui/src/components/Header/HeaderMetadataUsers.module.css create mode 100644 packages/ui/src/components/Header/HeaderMetadataUsers.tsx diff --git a/docs-ui/src/app/components/header/components.tsx b/docs-ui/src/app/components/header/components.tsx index 1a34570c0b..45d07f0981 100644 --- a/docs-ui/src/app/components/header/components.tsx +++ b/docs-ui/src/app/components/header/components.tsx @@ -1,6 +1,7 @@ 'use client'; import { Header } from '../../../../../packages/ui/src/components/Header/Header'; +import { HeaderMetadataUsers } from '../../../../../packages/ui/src/components/Header/HeaderMetadataUsers'; import { Button } from '../../../../../packages/ui/src/components/Button/Button'; import { ButtonIcon } from '../../../../../packages/ui/src/components/ButtonIcon/ButtonIcon'; import { @@ -11,6 +12,16 @@ import { import { MemoryRouter } from 'react-router-dom'; import { RiMore2Line } from '@remixicon/react'; +const users = { + giles: { + name: 'Giles Peyton-Nicoll', + src: 'https://i.pravatar.cc/150?u=giles', + }, + alice: { name: 'Alice Johnson', src: 'https://i.pravatar.cc/150?u=alice42' }, + bob: { name: 'Bob Smith', src: 'https://i.pravatar.cc/150?u=bob' }, + carol: { name: 'Carol Williams', src: 'https://i.pravatar.cc/150?u=carol' }, +}; + const tabs = [ { id: 'overview', label: 'Overview', href: '/overview' }, { id: 'checks', label: 'Checks', href: '/checks' }, @@ -35,10 +46,18 @@ const tags = [ { label: 'Gold' }, ]; -const metadata = [ - { label: 'Owner', value: 'platform-team' }, +const metadataUsers = [ { label: 'Type', value: 'website' }, - { label: 'Tier', value: 'gold' }, + { + label: 'Owner', + value: , + }, + { + label: 'Contributors', + value: ( + + ), + }, ]; export const WithEverything = () => ( @@ -47,8 +66,8 @@ export const WithEverything = () => ( title="Page Title" tags={tags} description="A short description of this page. Supports [inline links](https://backstage.io) and **bold text**." + metadata={metadataUsers} tabs={tabs.slice(0, 2)} - breadcrumbs={breadcrumbs.slice(0, 2)} customActions={ <> @@ -59,6 +78,12 @@ export const WithEverything = () => ( ); +export const WithMetadataUsers = () => ( + +
+ +); + export const WithTags = () => (
@@ -76,7 +101,14 @@ export const WithDescription = () => ( export const WithMetadata = () => ( -
+
); diff --git a/docs-ui/src/app/components/header/page.mdx b/docs-ui/src/app/components/header/page.mdx index a38aac2864..cc8dc531c2 100644 --- a/docs-ui/src/app/components/header/page.mdx +++ b/docs-ui/src/app/components/header/page.mdx @@ -3,11 +3,11 @@ import { CodeBlock } from '@/components/CodeBlock'; import { Snippet } from '@/components/Snippet'; import { WithEverything, - WithLongBreadcrumbs, WithTabs, WithTags, WithDescription, WithMetadata, + WithMetadataUsers, WithCustomActions, WithMenu, } from './components'; @@ -19,7 +19,7 @@ import { withTags, withDescription, withMetadata, - withBreadcrumbs, + withMetadataUsers, withCustomActions, withMenu, } from './snippets'; @@ -63,6 +63,12 @@ Key-value pairs displayed below the description. } code={withMetadata} /> +### Metadata with users + +Use `HeaderMetadataUsers` as the metadata value to display users as avatars. A single user shows the avatar with their name beside it. Multiple users show a row of avatars — hover to reveal each name via tooltip. + +} code={withMetadataUsers} /> + ### Tabs Tabs auto-detect the active tab from the current route when `activeTabId` is omitted. Pass an explicit `activeTabId` to override, or `null` for no active tab. @@ -79,12 +85,6 @@ Use `customActions` to add a dropdown menu. } code={withMenu} /> -### Breadcrumbs (deprecated) - -The `breadcrumbs` prop is deprecated and will be removed in a future release. Labels are truncated at 240px. - -} code={withBreadcrumbs} /> - diff --git a/docs-ui/src/app/components/header/props-definition.tsx b/docs-ui/src/app/components/header/props-definition.tsx index 3106e0853b..51d6ddee57 100644 --- a/docs-ui/src/app/components/header/props-definition.tsx +++ b/docs-ui/src/app/components/header/props-definition.tsx @@ -39,12 +39,13 @@ export const headerPagePropDefs: Record = { label: { type: 'string', required: true, - description: 'The key label, displayed in bold.', + description: 'The key label, displayed in secondary color.', }, value: { - type: 'ReactNode', + type: 'string | ReactNode', required: true, - description: 'The value to display alongside the label.', + description: + 'The value to display alongside the label. Pass a string for plain text or a ReactNode for custom content such as HeaderMetadataUsers.', }, }, }, diff --git a/docs-ui/src/app/components/header/snippets.ts b/docs-ui/src/app/components/header/snippets.ts index d5ded94872..99948e84d6 100644 --- a/docs-ui/src/app/components/header/snippets.ts +++ b/docs-ui/src/app/components/header/snippets.ts @@ -2,7 +2,9 @@ export const usage = `import { Header } from '@backstage/ui';
`; -export const defaultSnippet = `
, + }, + { + label: 'Contributors', + value: ( + + ), + }, + ]} tabs={[ { id: 'overview', label: 'Overview', href: '/overview' }, - { id: 'settings', label: 'Settings', href: '/settings' }, + { id: 'checks', label: 'Checks', href: '/checks' }, ]} customActions={ <> @@ -79,3 +100,27 @@ export const withMetadata = `
`; + +export const withMetadataUsers = `import { Header, HeaderMetadataUsers } from '@backstage/ui'; + +
, + }, + { + label: 'Contributors', + value: ( + + ), + }, + ]} +/>`; diff --git a/packages/ui/src/components/Header/Header.module.css b/packages/ui/src/components/Header/Header.module.css index 2ab1eb0e8f..ccceaf6f58 100644 --- a/packages/ui/src/components/Header/Header.module.css +++ b/packages/ui/src/components/Header/Header.module.css @@ -71,4 +71,11 @@ gap: 20px; flex-wrap: wrap; } + + .bui-HeaderMetaItem { + display: flex; + flex-direction: row; + align-items: center; + gap: var(--bui-space-2); + } } diff --git a/packages/ui/src/components/Header/Header.stories.tsx b/packages/ui/src/components/Header/Header.stories.tsx index 0cfe6c03bf..ac476c987b 100644 --- a/packages/ui/src/components/Header/Header.stories.tsx +++ b/packages/ui/src/components/Header/Header.stories.tsx @@ -17,6 +17,7 @@ import preview from '../../../../../.storybook/preview'; import type { StoryFn } from '@storybook/react-vite'; import { Header } from './Header'; +import { HeaderMetadataUsers } from './HeaderMetadataUsers'; import type { HeaderNavTabItem } from './types'; import { MemoryRouter } from 'react-router-dom'; import { BUIProvider } from '../../provider'; @@ -180,50 +181,105 @@ export const WithMetadata = meta.story({ metadata: [ { label: 'Owner', value: 'platform-team' }, { label: 'Type', value: 'website' }, - { label: 'Tier', value: 'gold' }, ], }, }); +const users = { + giles: { + name: 'Giles Peyton-Nicoll', + src: 'https://i.pravatar.cc/150?u=giles', + }, + alice: { name: 'Alice Johnson', src: 'https://i.pravatar.cc/150?u=alicej' }, + bob: { name: 'Bob Smith', src: 'https://i.pravatar.cc/150?u=bob' }, + carol: { name: 'Carol Williams', src: 'https://i.pravatar.cc/150?u=carol' }, +}; + +export const WithMetadataUsers = meta.story({ + decorators: [withRouter], + render: () => ( +
, + }, + { + label: 'Contributors', + value: ( + + ), + }, + ]} + /> + ), +}); + export const WithDescriptionTagsAndMetadata = meta.story({ decorators: [withRouter], - args: { - ...Default.input.args, - description: - 'This is a description of the page. It can include [inline links](https://backstage.io) and **bold text**.', - tags: [ - { label: 'TypeScript' }, - { label: 'Platform', href: '/platform' }, - { label: 'Gold' }, - ], - metadata: [ - { label: 'Owner', value: 'platform-team' }, - { label: 'Type', value: 'website' }, - { label: 'Tier', value: 'gold' }, - ], - }, + render: () => ( +
, + }, + { + label: 'Contributors', + value: ( + + ), + }, + { label: 'Type', value: 'website' }, + { label: 'Tier', value: 'gold' }, + ]} + /> + ), }); export const WithEverything = meta.story({ decorators: [withRouter], - args: { - ...Default.input.args, - tabs, - customActions: , - breadcrumbs: [{ label: 'Home', href: '/' }], - description: - 'This is a description of the page. It can include [inline links](https://backstage.io) and **bold text**.', - tags: [ - { label: 'TypeScript' }, - { label: 'Platform', href: '/platform' }, - { label: 'Gold' }, - ], - metadata: [ - { label: 'Owner', value: 'platform-team' }, - { label: 'Type', value: 'website' }, - { label: 'Tier', value: 'gold' }, - ], - }, + render: () => ( +
Custom action} + breadcrumbs={[{ label: 'Home', href: '/' }]} + description="This is a description of the page. It can include [inline links](https://backstage.io) and **bold text**." + tags={[ + { label: 'TypeScript' }, + { label: 'Platform', href: '/platform' }, + { label: 'Gold' }, + ]} + metadata={[ + { label: 'Type', value: 'website' }, + { + label: 'Owner', + value: , + }, + { + label: 'Contributors', + value: ( + + ), + }, + ]} + /> + ), }); const groupedTabs: HeaderNavTabItem[] = [ diff --git a/packages/ui/src/components/Header/Header.tsx b/packages/ui/src/components/Header/Header.tsx index 868b084312..0ddb1c6043 100644 --- a/packages/ui/src/components/Header/Header.tsx +++ b/packages/ui/src/components/Header/Header.tsx @@ -52,11 +52,11 @@ export const Header = (props: HeaderProps) => { {i > 0 && } {tag.href ? ( - + {tag.label} ) : ( - + {tag.label} )} @@ -113,9 +113,12 @@ export const Header = (props: HeaderProps) => { {metadata && metadata.length > 0 && (
{metadata.map(item => ( - - {item.label}: {item.value} - +
+ + {item.label} + + {item.value} +
))}
)} diff --git a/packages/ui/src/components/Header/HeaderMetadataUsers.module.css b/packages/ui/src/components/Header/HeaderMetadataUsers.module.css new file mode 100644 index 0000000000..8f4c3edc84 --- /dev/null +++ b/packages/ui/src/components/Header/HeaderMetadataUsers.module.css @@ -0,0 +1,33 @@ +/* + * Copyright 2026 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +@layer tokens, base, components, utilities; + +@layer components { + .single { + display: flex; + flex-direction: row; + align-items: center; + gap: var(--bui-space-2); + } + + .stack { + display: flex; + flex-direction: row; + align-items: center; + gap: var(--bui-space-1); + } +} diff --git a/packages/ui/src/components/Header/HeaderMetadataUsers.tsx b/packages/ui/src/components/Header/HeaderMetadataUsers.tsx new file mode 100644 index 0000000000..8bb972eb6b --- /dev/null +++ b/packages/ui/src/components/Header/HeaderMetadataUsers.tsx @@ -0,0 +1,70 @@ +/* + * Copyright 2026 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import type { HeaderMetadataUser } from './types'; +import { Avatar } from '../Avatar'; +import { Tooltip, TooltipTrigger } from '../Tooltip'; +import { Text } from '../Text'; +import { Pressable } from 'react-aria'; +import styles from './HeaderMetadataUsers.module.css'; + +/** + * Displays a list of users as avatars inside a Header metadata value. + * A single user shows the avatar with their name beside it. + * Multiple users show overlapping avatars with the name revealed on hover via tooltip. + * + * @public + */ +export const HeaderMetadataUsers = ({ + users, +}: { + users: HeaderMetadataUser[]; +}) => { + if (users.length === 0) return null; + + if (users.length === 1) { + const user = users[0]; + return ( +
+ + {user.name} +
+ ); + } + + return ( +
+ {users.map(user => ( + + + + + {user.name} + + ))} +
+ ); +}; diff --git a/packages/ui/src/components/Header/definition.ts b/packages/ui/src/components/Header/definition.ts index 29e813974b..7c9878d7e9 100644 --- a/packages/ui/src/components/Header/definition.ts +++ b/packages/ui/src/components/Header/definition.ts @@ -34,6 +34,7 @@ export const HeaderDefinition = defineComponent()({ tagDivider: 'bui-HeaderTagDivider', description: 'bui-HeaderDescription', metaRow: 'bui-HeaderMetaRow', + metaItem: 'bui-HeaderMetaItem', }, propDefs: { title: {}, diff --git a/packages/ui/src/components/Header/index.tsx b/packages/ui/src/components/Header/index.tsx index 586ee32bfb..6de3b01df3 100644 --- a/packages/ui/src/components/Header/index.tsx +++ b/packages/ui/src/components/Header/index.tsx @@ -20,6 +20,7 @@ export { HeaderNavItemDefinition, HeaderNavGroupDefinition, } from './HeaderNavDefinition'; +export { HeaderMetadataUsers } from './HeaderMetadataUsers'; export type { HeaderNavTab, HeaderNavTabGroup, @@ -27,6 +28,9 @@ export type { HeaderOwnProps, HeaderProps, HeaderBreadcrumb, + HeaderTag, + HeaderMetadataItem, + HeaderMetadataUser, HeaderPageOwnProps, HeaderPageProps, HeaderPageBreadcrumb, diff --git a/packages/ui/src/components/Header/types.ts b/packages/ui/src/components/Header/types.ts index b2c687fcf5..3baebcf7ba 100644 --- a/packages/ui/src/components/Header/types.ts +++ b/packages/ui/src/components/Header/types.ts @@ -72,6 +72,16 @@ export interface HeaderMetadataItem { value: React.ReactNode; } +/** + * Represents a user in the HeaderMetadataUsers component. + * + * @public + */ +export interface HeaderMetadataUser { + name: string; + src?: string; +} + /** * Own props for the Header component. * From 631079055677d71ec95a79ce94f18a043394da89 Mon Sep 17 00:00:00 2001 From: Charles de Dreuille Date: Sun, 19 Apr 2026 11:41:55 +0200 Subject: [PATCH 056/434] feat(ui): add href support to HeaderMetadataUsers, remove bold from description, full-width header MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add `href` to `HeaderMetadataUser`: avatar becomes a link and name renders as a primary `Link` with `standalone` (no underline at rest) - Add `WithMetadataUsersNoLinks` story alongside `WithMetadataUsers` to cover both link and non-link variants - Remove `strong`/`em` from ReactMarkdown `allowedElements` in Header description — only plain text and inline links are now supported - Replace `` root with a plain `
` so the Header spans its full parent width - Add `HeaderMetadataStatus` component and CSS (new files) - Update docs: fixtures, snippets, props-definition, and page.mdx to reflect all changes Signed-off-by: Charles de Dreuille Made-with: Cursor --- .../src/app/components/header/components.tsx | 77 ++++++++++++++-- docs-ui/src/app/components/header/page.mdx | 16 +++- .../components/header/props-definition.tsx | 32 ++++++- docs-ui/src/app/components/header/snippets.ts | 49 +++++++--- .../src/components/Header/Header.stories.tsx | 89 +++++++++++++++++-- packages/ui/src/components/Header/Header.tsx | 14 +-- .../Header/HeaderMetadataStatus.module.css | 49 ++++++++++ .../Header/HeaderMetadataStatus.tsx | 47 ++++++++++ .../Header/HeaderMetadataUsers.module.css | 5 ++ .../components/Header/HeaderMetadataUsers.tsx | 66 ++++++++++---- packages/ui/src/components/Header/index.tsx | 2 + packages/ui/src/components/Header/types.ts | 16 +++- 12 files changed, 406 insertions(+), 56 deletions(-) create mode 100644 packages/ui/src/components/Header/HeaderMetadataStatus.module.css create mode 100644 packages/ui/src/components/Header/HeaderMetadataStatus.tsx diff --git a/docs-ui/src/app/components/header/components.tsx b/docs-ui/src/app/components/header/components.tsx index 45d07f0981..2a583e6c80 100644 --- a/docs-ui/src/app/components/header/components.tsx +++ b/docs-ui/src/app/components/header/components.tsx @@ -2,6 +2,7 @@ import { Header } from '../../../../../packages/ui/src/components/Header/Header'; import { HeaderMetadataUsers } from '../../../../../packages/ui/src/components/Header/HeaderMetadataUsers'; +import { HeaderMetadataStatus } from '../../../../../packages/ui/src/components/Header/HeaderMetadataStatus'; import { Button } from '../../../../../packages/ui/src/components/Button/Button'; import { ButtonIcon } from '../../../../../packages/ui/src/components/ButtonIcon/ButtonIcon'; import { @@ -16,10 +17,23 @@ const users = { giles: { name: 'Giles Peyton-Nicoll', src: 'https://i.pravatar.cc/150?u=giles', + href: '/users/giles', + }, + alice: { + name: 'Alice Johnson', + src: 'https://i.pravatar.cc/150?u=alice42', + href: '/users/alice', + }, + bob: { + name: 'Bob Smith', + src: 'https://i.pravatar.cc/150?u=bob', + href: '/users/bob', + }, + carol: { + name: 'Carol Williams', + src: 'https://i.pravatar.cc/150?u=carol', + href: '/users/carol', }, - alice: { name: 'Alice Johnson', src: 'https://i.pravatar.cc/150?u=alice42' }, - bob: { name: 'Bob Smith', src: 'https://i.pravatar.cc/150?u=bob' }, - carol: { name: 'Carol Williams', src: 'https://i.pravatar.cc/150?u=carol' }, }; const tabs = [ @@ -43,11 +57,14 @@ const breadcrumbs = [ const tags = [ { label: 'TypeScript' }, { label: 'Platform', href: '/platform' }, - { label: 'Gold' }, ]; const metadataUsers = [ { label: 'Type', value: 'website' }, + { + label: 'Status', + value: , + }, { label: 'Owner', value: , @@ -65,7 +82,7 @@ export const WithEverything = () => (
( export const WithMetadataUsers = () => ( -
+
, + }, + { + label: 'Contributors', + value: ( + + ), + }, + ]} + /> ); @@ -94,7 +128,7 @@ export const WithDescription = () => (
); @@ -106,7 +140,34 @@ export const WithMetadata = () => ( metadata={[ { label: 'Owner', value: 'platform-team' }, { label: 'Type', value: 'website' }, - { label: 'Tier', value: 'gold' }, + ]} + /> + +); + +export const WithMetadataStatus = () => ( + +
, + }, + { + label: 'Build', + value: ( + + ), + }, + { + label: 'Coverage', + value: , + }, ]} /> diff --git a/docs-ui/src/app/components/header/page.mdx b/docs-ui/src/app/components/header/page.mdx index cc8dc531c2..0a31a10f29 100644 --- a/docs-ui/src/app/components/header/page.mdx +++ b/docs-ui/src/app/components/header/page.mdx @@ -8,10 +8,11 @@ import { WithDescription, WithMetadata, WithMetadataUsers, + WithMetadataStatus, WithCustomActions, WithMenu, } from './components'; -import { headerPagePropDefs } from './props-definition'; +import { headerPagePropDefs, headerMetadataUsersPropDefs } from './props-definition'; import { usage, defaultSnippet, @@ -20,6 +21,7 @@ import { withDescription, withMetadata, withMetadataUsers, + withMetadataStatus, withCustomActions, withMenu, } from './snippets'; @@ -53,7 +55,7 @@ Tags are rendered above the title. Each tag with an `href` renders as a link; ta ### Description -The description accepts a markdown string. Only inline elements are supported — links, bold, and italic. +The description accepts a markdown string with support for inline links. Bold, italic, and block-level markdown are not rendered. } code={withDescription} /> @@ -65,10 +67,18 @@ Key-value pairs displayed below the description. ### Metadata with users -Use `HeaderMetadataUsers` as the metadata value to display users as avatars. A single user shows the avatar with their name beside it. Multiple users show a row of avatars — hover to reveal each name via tooltip. +Use `HeaderMetadataUsers` as the metadata value to display users as avatars. A single user shows the avatar with their name beside it. Multiple users show a row of avatars — hover to reveal each name via tooltip. When a user has an `href`, the avatar and name become links. } code={withMetadataUsers} /> + + +### Metadata with status + +Use `HeaderMetadataStatus` as the metadata value to display a status indicator. The dot colour is driven by the `color` prop which maps to BUI status tokens. Pass an `href` to make the label a link. + +} code={withMetadataStatus} /> + ### Tabs Tabs auto-detect the active tab from the current route when `activeTabId` is omitted. Pass an explicit `activeTabId` to override, or `null` for no active tab. diff --git a/docs-ui/src/app/components/header/props-definition.tsx b/docs-ui/src/app/components/header/props-definition.tsx index 51d6ddee57..f4e7c2b670 100644 --- a/docs-ui/src/app/components/header/props-definition.tsx +++ b/docs-ui/src/app/components/header/props-definition.tsx @@ -28,7 +28,7 @@ export const headerPagePropDefs: Record = { description: { type: 'string', description: - 'Markdown string rendered below the title. Only inline elements are supported: links, bold, and italic. Block-level markdown such as headings or lists is not rendered.', + 'Markdown string rendered below the title. Only inline links are supported. Bold, italic, and block-level markdown are not rendered.', }, metadata: { type: 'complex', @@ -114,3 +114,33 @@ export const headerPagePropDefs: Record = { }, ...classNamePropDefs, }; + +export const headerMetadataUsersPropDefs: Record = { + users: { + type: 'complex', + description: + 'List of users to display. A single user shows the avatar with their name beside it. Multiple users show a row of avatars with names revealed on hover via tooltip.', + complexType: { + name: 'HeaderMetadataUser[]', + properties: { + name: { + type: 'string', + required: true, + description: + 'Display name shown beside the avatar (single) or in the tooltip (multiple).', + }, + src: { + type: 'string', + required: false, + description: 'URL for the avatar image.', + }, + href: { + type: 'string', + required: false, + description: + 'When provided, the avatar becomes a link and the name is rendered as a Link component.', + }, + }, + }, + }, +}; diff --git a/docs-ui/src/app/components/header/snippets.ts b/docs-ui/src/app/components/header/snippets.ts index 99948e84d6..ac38d6d7eb 100644 --- a/docs-ui/src/app/components/header/snippets.ts +++ b/docs-ui/src/app/components/header/snippets.ts @@ -2,30 +2,33 @@ export const usage = `import { Header } from '@backstage/ui';
`; -export const defaultSnippet = `import { Header, HeaderMetadataUsers } from '@backstage/ui'; +export const defaultSnippet = `import { Header, HeaderMetadataUsers, HeaderMetadataStatus } from '@backstage/ui';
, + }, { label: 'Owner', - value: , + value: , }, { label: 'Contributors', value: ( ), @@ -89,7 +92,7 @@ export const withTags = `
`; export const withMetadata = `
`; + +export const withMetadataStatus = `import { Header, HeaderMetadataStatus } from '@backstage/ui'; + +
, + }, + { + label: 'Build', + value: , + }, + { + label: 'Coverage', + value: , + }, ]} />`; @@ -106,18 +128,19 @@ export const withMetadataUsers = `import { Header, HeaderMetadataUsers } from '@
, + value: , }, { label: 'Contributors', value: ( ), diff --git a/packages/ui/src/components/Header/Header.stories.tsx b/packages/ui/src/components/Header/Header.stories.tsx index ac476c987b..77994ab4d9 100644 --- a/packages/ui/src/components/Header/Header.stories.tsx +++ b/packages/ui/src/components/Header/Header.stories.tsx @@ -18,6 +18,7 @@ import preview from '../../../../../.storybook/preview'; import type { StoryFn } from '@storybook/react-vite'; import { Header } from './Header'; import { HeaderMetadataUsers } from './HeaderMetadataUsers'; +import { HeaderMetadataStatus } from './HeaderMetadataStatus'; import type { HeaderNavTabItem } from './types'; import { MemoryRouter } from 'react-router-dom'; import { BUIProvider } from '../../provider'; @@ -27,9 +28,6 @@ import { RiMore2Line } from '@remixicon/react'; const meta = preview.meta({ title: 'Backstage UI/Header', component: Header, - parameters: { - layout: 'fullscreen', - }, }); const tabs: HeaderNavTabItem[] = [ @@ -158,7 +156,7 @@ export const WithDescription = meta.story({ args: { ...Default.input.args, description: - 'This is a description of the page. It can include [inline links](https://backstage.io) and **bold text**.', + 'This is a description of the page. It can include [inline links](https://backstage.io).', }, }); @@ -189,10 +187,23 @@ const users = { giles: { name: 'Giles Peyton-Nicoll', src: 'https://i.pravatar.cc/150?u=giles', + href: '/users/giles', + }, + alice: { + name: 'Alice Johnson', + src: 'https://i.pravatar.cc/150?u=alicej', + href: '/users/alice', + }, + bob: { + name: 'Bob Smith', + src: 'https://i.pravatar.cc/150?u=bob', + href: '/users/bob', + }, + carol: { + name: 'Carol Williams', + src: 'https://i.pravatar.cc/150?u=carol', + href: '/users/carol', }, - alice: { name: 'Alice Johnson', src: 'https://i.pravatar.cc/150?u=alicej' }, - bob: { name: 'Bob Smith', src: 'https://i.pravatar.cc/150?u=bob' }, - carol: { name: 'Carol Williams', src: 'https://i.pravatar.cc/150?u=carol' }, }; export const WithMetadataUsers = meta.story({ @@ -218,12 +229,72 @@ export const WithMetadataUsers = meta.story({ ), }); +export const WithMetadataUsersNoLinks = meta.story({ + decorators: [withRouter], + render: () => ( +
+ ), + }, + { + label: 'Contributors', + value: ( + + ), + }, + ]} + /> + ), +}); + +export const WithMetadataStatus = meta.story({ + decorators: [withRouter], + render: () => ( +
, + }, + { + label: 'Build', + value: ( + + ), + }, + { + label: 'Coverage', + value: , + }, + ]} + /> + ), +}); + export const WithDescriptionTagsAndMetadata = meta.story({ decorators: [withRouter], render: () => (
Custom action} breadcrumbs={[{ label: 'Home', href: '/' }]} - description="This is a description of the page. It can include [inline links](https://backstage.io) and **bold text**." + description="This is a description of the page. It can include [inline links](https://backstage.io)." tags={[ { label: 'TypeScript' }, { label: 'Platform', href: '/platform' }, diff --git a/packages/ui/src/components/Header/Header.tsx b/packages/ui/src/components/Header/Header.tsx index 0ddb1c6043..2518a6716d 100644 --- a/packages/ui/src/components/Header/Header.tsx +++ b/packages/ui/src/components/Header/Header.tsx @@ -20,7 +20,6 @@ import { RiArrowRightSLine } from '@remixicon/react'; import { HeaderNav } from './HeaderNav'; import { useDefinition } from '../../hooks/useDefinition'; import { HeaderDefinition } from './definition'; -import { Container } from '../Container'; import { Link } from '../Link'; import { Fragment } from 'react/jsx-runtime'; import ReactMarkdown from 'react-markdown'; @@ -45,14 +44,19 @@ export const Header = (props: HeaderProps) => { } = ownProps; return ( - +
{tags && tags.length > 0 && (
{tags.map((tag, i) => ( {i > 0 && } {tag.href ? ( - + {tag.label} ) : ( @@ -92,7 +96,7 @@ export const Header = (props: HeaderProps) => { {description && ( ( @@ -127,7 +131,7 @@ export const Header = (props: HeaderProps) => {
)} - +
); }; diff --git a/packages/ui/src/components/Header/HeaderMetadataStatus.module.css b/packages/ui/src/components/Header/HeaderMetadataStatus.module.css new file mode 100644 index 0000000000..ec4a9c9fd7 --- /dev/null +++ b/packages/ui/src/components/Header/HeaderMetadataStatus.module.css @@ -0,0 +1,49 @@ +/* + * Copyright 2026 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +@layer tokens, base, components, utilities; + +@layer components { + .single { + display: flex; + flex-direction: row; + align-items: center; + gap: var(--bui-space-2); + } + + .dot { + width: 8px; + height: 8px; + border-radius: 50%; + flex-shrink: 0; + } + + .dot-danger { + background-color: var(--bui-fg-danger); + } + + .dot-warning { + background-color: var(--bui-fg-warning); + } + + .dot-success { + background-color: var(--bui-fg-success); + } + + .dot-info { + background-color: var(--bui-fg-info); + } +} diff --git a/packages/ui/src/components/Header/HeaderMetadataStatus.tsx b/packages/ui/src/components/Header/HeaderMetadataStatus.tsx new file mode 100644 index 0000000000..75c9ea778f --- /dev/null +++ b/packages/ui/src/components/Header/HeaderMetadataStatus.tsx @@ -0,0 +1,47 @@ +/* + * Copyright 2026 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import type { HeaderMetadataStatusItem } from './types'; +import { Text } from '../Text'; +import { Link } from '../Link'; +import styles from './HeaderMetadataStatus.module.css'; + +/** + * Displays a single status indicator as a coloured dot with a label inside a + * Header metadata value. Optionally renders the label as a link when href is provided. + * + * @public + */ +export const HeaderMetadataStatus = ({ + label, + color, + href, +}: HeaderMetadataStatusItem) => { + return ( +
+ + + {href ? ( + + {label} + + ) : ( + label + )} + +
+ ); +}; diff --git a/packages/ui/src/components/Header/HeaderMetadataUsers.module.css b/packages/ui/src/components/Header/HeaderMetadataUsers.module.css index 8f4c3edc84..745e768d56 100644 --- a/packages/ui/src/components/Header/HeaderMetadataUsers.module.css +++ b/packages/ui/src/components/Header/HeaderMetadataUsers.module.css @@ -30,4 +30,9 @@ align-items: center; gap: var(--bui-space-1); } + + .avatarLink { + display: flex; + text-decoration: none; + } } diff --git a/packages/ui/src/components/Header/HeaderMetadataUsers.tsx b/packages/ui/src/components/Header/HeaderMetadataUsers.tsx index 8bb972eb6b..3f68398ca5 100644 --- a/packages/ui/src/components/Header/HeaderMetadataUsers.tsx +++ b/packages/ui/src/components/Header/HeaderMetadataUsers.tsx @@ -18,6 +18,7 @@ import type { HeaderMetadataUser } from './types'; import { Avatar } from '../Avatar'; import { Tooltip, TooltipTrigger } from '../Tooltip'; import { Text } from '../Text'; +import { Link } from '../Link'; import { Pressable } from 'react-aria'; import styles from './HeaderMetadataUsers.module.css'; @@ -25,6 +26,7 @@ import styles from './HeaderMetadataUsers.module.css'; * Displays a list of users as avatars inside a Header metadata value. * A single user shows the avatar with their name beside it. * Multiple users show overlapping avatars with the name revealed on hover via tooltip. + * When a user has an `href`, the avatar and name become links. * * @public */ @@ -37,15 +39,34 @@ export const HeaderMetadataUsers = ({ if (users.length === 1) { const user = users[0]; + const avatar = ( + + ); return (
- - {user.name} + {user.href ? ( + + {avatar} + + ) : ( + avatar + )} + {user.href ? ( + + {user.name} + + ) : ( + {user.name} + )}
); } @@ -54,14 +75,29 @@ export const HeaderMetadataUsers = ({
{users.map(user => ( - - - + {user.href ? ( + + + + ) : ( + + + + )} {user.name} ))} diff --git a/packages/ui/src/components/Header/index.tsx b/packages/ui/src/components/Header/index.tsx index 6de3b01df3..3cdced383d 100644 --- a/packages/ui/src/components/Header/index.tsx +++ b/packages/ui/src/components/Header/index.tsx @@ -21,6 +21,7 @@ export { HeaderNavGroupDefinition, } from './HeaderNavDefinition'; export { HeaderMetadataUsers } from './HeaderMetadataUsers'; +export { HeaderMetadataStatus } from './HeaderMetadataStatus'; export type { HeaderNavTab, HeaderNavTabGroup, @@ -31,6 +32,7 @@ export type { HeaderTag, HeaderMetadataItem, HeaderMetadataUser, + HeaderMetadataStatusItem, HeaderPageOwnProps, HeaderPageProps, HeaderPageBreadcrumb, diff --git a/packages/ui/src/components/Header/types.ts b/packages/ui/src/components/Header/types.ts index 3baebcf7ba..db03c6968c 100644 --- a/packages/ui/src/components/Header/types.ts +++ b/packages/ui/src/components/Header/types.ts @@ -80,6 +80,18 @@ export interface HeaderMetadataItem { export interface HeaderMetadataUser { name: string; src?: string; + href?: string; +} + +/** + * Represents a status item in the HeaderMetadataStatus component. + * + * @public + */ +export interface HeaderMetadataStatusItem { + label: string; + color: 'danger' | 'warning' | 'success' | 'info'; + href?: string; } /** @@ -97,8 +109,8 @@ export interface HeaderOwnProps { */ breadcrumbs?: HeaderBreadcrumb[]; /** - * Markdown string rendered below the title. Only inline elements are - * supported (links, bold, italic). Block-level markdown is not rendered. + * Markdown string rendered below the title. Only inline links are supported. + * Bold, italic, and block-level markdown are not rendered. */ description?: string; tags?: HeaderTag[]; From 9a5a3274cc660de2a2808dc44fd15996b88ebbbe Mon Sep 17 00:00:00 2001 From: Charles de Dreuille Date: Sun, 19 Apr 2026 11:44:11 +0200 Subject: [PATCH 057/434] chore: update lockfile after adding react-markdown dependency Signed-off-by: Charles de Dreuille Made-with: Cursor --- yarn.lock | 1 + 1 file changed, 1 insertion(+) diff --git a/yarn.lock b/yarn.lock index 6802845bac..7726a28c2a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7987,6 +7987,7 @@ __metadata: react-aria: "npm:~3.48.0" react-aria-components: "npm:~1.17.0" react-dom: "npm:^18.0.2" + react-markdown: "npm:^8.0.0" react-router-dom: "npm:^6.30.2" react-stately: "npm:~3.46.0" storybook: "npm:^10.3.3" From 039751e3f72f1430320741be8daa2b7320c76289 Mon Sep 17 00:00:00 2001 From: Charles de Dreuille Date: Sun, 19 Apr 2026 19:48:46 +0100 Subject: [PATCH 058/434] chore: fix prettier formatting in header page.mdx Signed-off-by: Charles de Dreuille Made-with: Cursor --- docs-ui/src/app/components/header/page.mdx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/docs-ui/src/app/components/header/page.mdx b/docs-ui/src/app/components/header/page.mdx index 0a31a10f29..107bf6d7f9 100644 --- a/docs-ui/src/app/components/header/page.mdx +++ b/docs-ui/src/app/components/header/page.mdx @@ -12,7 +12,10 @@ import { WithCustomActions, WithMenu, } from './components'; -import { headerPagePropDefs, headerMetadataUsersPropDefs } from './props-definition'; +import { + headerPagePropDefs, + headerMetadataUsersPropDefs, +} from './props-definition'; import { usage, defaultSnippet, From 4e932ef781674e0127a6b0b0c701cd6f86d78932 Mon Sep 17 00:00:00 2001 From: Charles de Dreuille Date: Sun, 19 Apr 2026 19:51:53 +0100 Subject: [PATCH 059/434] chore(ui): regenerate API report Signed-off-by: Charles de Dreuille Made-with: Cursor --- packages/ui/report.api.md | 69 ++++++++++++++++++++++++++++++++++++++- 1 file changed, 68 insertions(+), 1 deletion(-) diff --git a/packages/ui/report.api.md b/packages/ui/report.api.md index 4856387abf..cb19d612b5 100644 --- a/packages/ui/report.api.md +++ b/packages/ui/report.api.md @@ -1508,6 +1508,11 @@ export const HeaderDefinition: { readonly breadcrumbs: 'bui-HeaderBreadcrumbs'; readonly tabsWrapper: 'bui-HeaderTabsWrapper'; readonly controls: 'bui-HeaderControls'; + readonly tags: 'bui-HeaderTags'; + readonly tagDivider: 'bui-HeaderTagDivider'; + readonly description: 'bui-HeaderDescription'; + readonly metaRow: 'bui-HeaderMetaRow'; + readonly metaItem: 'bui-HeaderMetaItem'; }; readonly propDefs: { readonly title: {}; @@ -1515,10 +1520,51 @@ export const HeaderDefinition: { readonly tabs: {}; readonly activeTabId: {}; readonly breadcrumbs: {}; + readonly description: {}; + readonly tags: {}; + readonly metadata: {}; readonly className: {}; }; }; +// @public +export interface HeaderMetadataItem { + // (undocumented) + label: string; + // (undocumented) + value: React.ReactNode; +} + +// @public +export const HeaderMetadataStatus: ( + input: HeaderMetadataStatusItem, +) => JSX_2.Element; + +// @public +export interface HeaderMetadataStatusItem { + // (undocumented) + color: 'danger' | 'warning' | 'success' | 'info'; + // (undocumented) + href?: string; + // (undocumented) + label: string; +} + +// @public +export interface HeaderMetadataUser { + // (undocumented) + href?: string; + // (undocumented) + name: string; + // (undocumented) + src?: string; +} + +// @public +export const HeaderMetadataUsers: (input: { + users: HeaderMetadataUser[]; +}) => JSX_2.Element | null; + // @public (undocumented) export const HeaderNavDefinition: { readonly styles: { @@ -1599,15 +1645,20 @@ export type HeaderNavTabItem = HeaderNavTab | HeaderNavTabGroup; export interface HeaderOwnProps { // (undocumented) activeTabId?: string | null; - // (undocumented) + // @deprecated (undocumented) breadcrumbs?: HeaderBreadcrumb[]; // (undocumented) className?: string; // (undocumented) customActions?: React.ReactNode; + description?: string; + // (undocumented) + metadata?: HeaderMetadataItem[]; // (undocumented) tabs?: HeaderNavTabItem[]; // (undocumented) + tags?: HeaderTag[]; + // (undocumented) title?: string; } @@ -1628,6 +1679,11 @@ export const HeaderPageDefinition: { readonly breadcrumbs: 'bui-HeaderBreadcrumbs'; readonly tabsWrapper: 'bui-HeaderTabsWrapper'; readonly controls: 'bui-HeaderControls'; + readonly tags: 'bui-HeaderTags'; + readonly tagDivider: 'bui-HeaderTagDivider'; + readonly description: 'bui-HeaderDescription'; + readonly metaRow: 'bui-HeaderMetaRow'; + readonly metaItem: 'bui-HeaderMetaItem'; }; readonly propDefs: { readonly title: {}; @@ -1635,6 +1691,9 @@ export const HeaderPageDefinition: { readonly tabs: {}; readonly activeTabId: {}; readonly breadcrumbs: {}; + readonly description: {}; + readonly tags: {}; + readonly metadata: {}; readonly className: {}; }; }; @@ -1659,6 +1718,14 @@ export interface HeaderTab { matchStrategy?: TabMatchStrategy; } +// @public +export interface HeaderTag { + // (undocumented) + href?: string; + // (undocumented) + label: string; +} + // @public (undocumented) export type JustifyContent = | 'stretch' From c0817115e8dcfe641f2c4e150d807ba93e1f4af0 Mon Sep 17 00:00:00 2001 From: Charles de Dreuille Date: Mon, 20 Apr 2026 10:38:44 +0100 Subject: [PATCH 060/434] chore: update API reports to match master Signed-off-by: Charles de Dreuille --- plugins/catalog-react/report-alpha.api.md | 4 ++-- plugins/catalog-react/report.api.md | 4 ++-- .../report.api.md | 16 ++++++++-------- plugins/scaffolder/report-alpha.api.md | 4 ++-- plugins/scaffolder/report.api.md | 4 ++-- 5 files changed, 16 insertions(+), 16 deletions(-) diff --git a/plugins/catalog-react/report-alpha.api.md b/plugins/catalog-react/report-alpha.api.md index feea770f23..5634b56df4 100644 --- a/plugins/catalog-react/report-alpha.api.md +++ b/plugins/catalog-react/report-alpha.api.md @@ -82,8 +82,8 @@ export const catalogReactTranslationRef: TranslationRef< readonly 'inspectEntityDialog.overviewPage.labels': 'Labels'; readonly 'inspectEntityDialog.overviewPage.status.title': 'Status'; readonly 'inspectEntityDialog.overviewPage.identity.title': 'Identity'; - readonly 'inspectEntityDialog.overviewPage.annotations': 'Annotations'; readonly 'inspectEntityDialog.overviewPage.tags': 'Tags'; + readonly 'inspectEntityDialog.overviewPage.annotations': 'Annotations'; readonly 'inspectEntityDialog.overviewPage.relation.title': 'Relations'; readonly 'inspectEntityDialog.overviewPage.copyAriaLabel': 'Copy {{label}}'; readonly 'inspectEntityDialog.overviewPage.copiedStatus': 'Copied'; @@ -122,8 +122,8 @@ export const catalogReactTranslationRef: TranslationRef< readonly 'entityTableColumnTitle.description': 'Description'; readonly 'entityTableColumnTitle.system': 'System'; readonly 'entityTableColumnTitle.namespace': 'Namespace'; - readonly 'entityTableColumnTitle.domain': 'Domain'; readonly 'entityTableColumnTitle.tags': 'Tags'; + readonly 'entityTableColumnTitle.domain': 'Domain'; readonly 'entityTableColumnTitle.owner': 'Owner'; readonly 'entityTableColumnTitle.lifecycle': 'Lifecycle'; readonly 'entityTableColumnTitle.targets': 'Targets'; diff --git a/plugins/catalog-react/report.api.md b/plugins/catalog-react/report.api.md index bf241251b0..1fcac904ff 100644 --- a/plugins/catalog-react/report.api.md +++ b/plugins/catalog-react/report.api.md @@ -204,8 +204,8 @@ export const catalogReactTranslationRef: TranslationRef< readonly 'inspectEntityDialog.overviewPage.labels': 'Labels'; readonly 'inspectEntityDialog.overviewPage.status.title': 'Status'; readonly 'inspectEntityDialog.overviewPage.identity.title': 'Identity'; - readonly 'inspectEntityDialog.overviewPage.annotations': 'Annotations'; readonly 'inspectEntityDialog.overviewPage.tags': 'Tags'; + readonly 'inspectEntityDialog.overviewPage.annotations': 'Annotations'; readonly 'inspectEntityDialog.overviewPage.relation.title': 'Relations'; readonly 'inspectEntityDialog.overviewPage.copyAriaLabel': 'Copy {{label}}'; readonly 'inspectEntityDialog.overviewPage.copiedStatus': 'Copied'; @@ -244,8 +244,8 @@ export const catalogReactTranslationRef: TranslationRef< readonly 'entityTableColumnTitle.description': 'Description'; readonly 'entityTableColumnTitle.system': 'System'; readonly 'entityTableColumnTitle.namespace': 'Namespace'; - readonly 'entityTableColumnTitle.domain': 'Domain'; readonly 'entityTableColumnTitle.tags': 'Tags'; + readonly 'entityTableColumnTitle.domain': 'Domain'; readonly 'entityTableColumnTitle.owner': 'Owner'; readonly 'entityTableColumnTitle.lifecycle': 'Lifecycle'; readonly 'entityTableColumnTitle.targets': 'Targets'; diff --git a/plugins/scaffolder-backend-module-github/report.api.md b/plugins/scaffolder-backend-module-github/report.api.md index 9e930a7866..382665eea1 100644 --- a/plugins/scaffolder-backend-module-github/report.api.md +++ b/plugins/scaffolder-backend-module-github/report.api.md @@ -66,15 +66,15 @@ export function createGithubBranchProtectionAction(options: { dismissStaleReviews?: boolean | undefined; bypassPullRequestAllowances?: | { + users?: string[] | undefined; apps?: string[] | undefined; teams?: string[] | undefined; - users?: string[] | undefined; } | undefined; restrictions?: | { - teams: string[]; users: string[]; + teams: string[]; apps?: string[] | undefined; } | undefined; @@ -241,9 +241,9 @@ export function createGithubRepoCreateAction(options: { branch?: string | undefined; bypassPullRequestAllowances?: | { + users?: string[] | undefined; apps?: string[] | undefined; teams?: string[] | undefined; - users?: string[] | undefined; } | undefined; collaborators?: @@ -290,8 +290,8 @@ export function createGithubRepoCreateAction(options: { requireLastPushApproval?: boolean | undefined; restrictions?: | { - teams: string[]; users: string[]; + teams: string[]; apps?: string[] | undefined; } | undefined; @@ -328,16 +328,16 @@ export function createGithubRepoPushAction(options: { requiredStatusCheckContexts?: string[] | undefined; bypassPullRequestAllowances?: | { + users?: string[] | undefined; apps?: string[] | undefined; teams?: string[] | undefined; - users?: string[] | undefined; } | undefined; requiredApprovingReviewCount?: number | undefined; restrictions?: | { - teams: string[]; users: string[]; + teams: string[]; apps?: string[] | undefined; } | undefined; @@ -398,16 +398,16 @@ export function createPublishGithubAction(options: { access?: string | undefined; bypassPullRequestAllowances?: | { + users?: string[] | undefined; apps?: string[] | undefined; teams?: string[] | undefined; - users?: string[] | undefined; } | undefined; requiredApprovingReviewCount?: number | undefined; restrictions?: | { - teams: string[]; users: string[]; + teams: string[]; apps?: string[] | undefined; } | undefined; diff --git a/plugins/scaffolder/report-alpha.api.md b/plugins/scaffolder/report-alpha.api.md index 10066f3fd3..45e52f5bcc 100644 --- a/plugins/scaffolder/report-alpha.api.md +++ b/plugins/scaffolder/report-alpha.api.md @@ -775,13 +775,13 @@ export const scaffolderTranslationRef: TranslationRef< readonly 'actionsPage.action.examples': 'Examples'; readonly 'actionsPage.subtitle': 'This is the collection of all installed actions'; readonly 'actionsPage.pageTitle': 'Create a New Component'; - readonly 'listTaskPage.content.emptyState.title': 'No information to display'; - readonly 'listTaskPage.content.emptyState.description': 'There are no tasks or there was an issue communicating with backend.'; readonly 'listTaskPage.content.tableCell.template': 'Template'; readonly 'listTaskPage.content.tableCell.status': 'Status'; readonly 'listTaskPage.content.tableCell.owner': 'Owner'; readonly 'listTaskPage.content.tableCell.created': 'Created'; readonly 'listTaskPage.content.tableCell.taskID': 'Task ID'; + readonly 'listTaskPage.content.emptyState.title': 'No information to display'; + readonly 'listTaskPage.content.emptyState.description': 'There are no tasks or there was an issue communicating with backend.'; readonly 'listTaskPage.content.tableTitle': 'Tasks'; readonly 'listTaskPage.title': 'List template tasks'; readonly 'listTaskPage.subtitle': 'All tasks that have been started'; diff --git a/plugins/scaffolder/report.api.md b/plugins/scaffolder/report.api.md index a0c35e1b5e..0d2756131d 100644 --- a/plugins/scaffolder/report.api.md +++ b/plugins/scaffolder/report.api.md @@ -673,13 +673,13 @@ export const scaffolderTranslationRef: TranslationRef< readonly 'actionsPage.action.examples': 'Examples'; readonly 'actionsPage.subtitle': 'This is the collection of all installed actions'; readonly 'actionsPage.pageTitle': 'Create a New Component'; - readonly 'listTaskPage.content.emptyState.title': 'No information to display'; - readonly 'listTaskPage.content.emptyState.description': 'There are no tasks or there was an issue communicating with backend.'; readonly 'listTaskPage.content.tableCell.template': 'Template'; readonly 'listTaskPage.content.tableCell.status': 'Status'; readonly 'listTaskPage.content.tableCell.owner': 'Owner'; readonly 'listTaskPage.content.tableCell.created': 'Created'; readonly 'listTaskPage.content.tableCell.taskID': 'Task ID'; + readonly 'listTaskPage.content.emptyState.title': 'No information to display'; + readonly 'listTaskPage.content.emptyState.description': 'There are no tasks or there was an issue communicating with backend.'; readonly 'listTaskPage.content.tableTitle': 'Tasks'; readonly 'listTaskPage.title': 'List template tasks'; readonly 'listTaskPage.subtitle': 'All tasks that have been started'; From 357d63949e533dd2511c467396eeb07638f6505b Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Mon, 20 Apr 2026 16:37:26 +0200 Subject: [PATCH 061/434] Fix lockfile dependency removal detection in PackageGraph The `otherGraph` variable in `listChangedPackages` was incorrectly created from `thisLockfile` instead of `otherLockfile`, making the merged dependency graph a duplicate of the current one. This meant that dependencies only present in the old lockfile were never added to the graph, so transitive removals could not be detected. Signed-off-by: Patrik Oldsberg Made-with: Cursor --- .changeset/fix-lockfile-removal-detection.md | 5 ++ .../src/monorepo/PackageGraph.test.ts | 47 +++++++++++++++++++ .../cli-node/src/monorepo/PackageGraph.ts | 2 +- 3 files changed, 53 insertions(+), 1 deletion(-) create mode 100644 .changeset/fix-lockfile-removal-detection.md diff --git a/.changeset/fix-lockfile-removal-detection.md b/.changeset/fix-lockfile-removal-detection.md new file mode 100644 index 0000000000..3f7d9541ae --- /dev/null +++ b/.changeset/fix-lockfile-removal-detection.md @@ -0,0 +1,5 @@ +--- +'@backstage/cli-node': patch +--- + +Fixed a bug in `PackageGraph.listChangedPackages` where removed dependencies were not detected during lockfile analysis. The dependency graph from the previous lockfile was not being merged, causing transitive dependency removals to be missed. diff --git a/packages/cli-node/src/monorepo/PackageGraph.test.ts b/packages/cli-node/src/monorepo/PackageGraph.test.ts index 51d8f7b7be..7ee41e939d 100644 --- a/packages/cli-node/src/monorepo/PackageGraph.test.ts +++ b/packages/cli-node/src/monorepo/PackageGraph.test.ts @@ -222,4 +222,51 @@ c-dep@^2: 'origin/master', ); }); + + it('detects packages affected by a removed dependency in the lockfile', async () => { + const graph = PackageGraph.fromPackages(testPackages); + + mockListChangedFiles.mockResolvedValueOnce(['yarn.lock']); + + // The old lockfile (at the ref) has b depending on b-dep + mockReadFileAtRef.mockResolvedValueOnce(` +a@^1: + version: "1.0.0" + +b@^1: + version: "1.0.0" + dependencies: + b-dep: ^1 + +b-dep@^1: + version: "1.0.0" + integrity: sha512-old + +c@^1: + version: "1.0.0" +`); + + // The current lockfile no longer has b-dep at all + jest.spyOn(Lockfile, 'load').mockResolvedValueOnce( + Lockfile.parse(` +a@^1: + version: "1.0.0" + +b@^1: + version: "1.0.0" + +c@^1: + version: "1.0.0" +`), + ); + + await expect( + graph + .listChangedPackages({ + ref: 'origin/master', + analyzeLockfile: true, + }) + .then(pkgs => pkgs.map(pkg => pkg.name)), + ).resolves.toEqual(['b']); + }); }); diff --git a/packages/cli-node/src/monorepo/PackageGraph.ts b/packages/cli-node/src/monorepo/PackageGraph.ts index 9d1745c7c5..35fe943606 100644 --- a/packages/cli-node/src/monorepo/PackageGraph.ts +++ b/packages/cli-node/src/monorepo/PackageGraph.ts @@ -393,7 +393,7 @@ export class PackageGraph extends Map { // Merge the dependency graph from the other lockfile into this one in // order to be able to detect removals accurately. { - const otherGraph = thisLockfile.createSimplifiedDependencyGraph(); + const otherGraph = otherLockfile.createSimplifiedDependencyGraph(); for (const [name, dependencies] of otherGraph) { const node = graph.get(name); if (node) { From df705bbdbfc416652602724ab4de042979f3acc8 Mon Sep 17 00:00:00 2001 From: Johan Persson Date: Mon, 20 Apr 2026 14:24:36 +0200 Subject: [PATCH 062/434] fix(ui): preserve external hrefs in BUI link components MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the app was served under a non-root base path, BUI link components rewrote absolute `href` values as in-app paths — e.g. `https://example.com` became `/basename/https:/example.com` — because every href was passed through react-router's `useHref`, which treats all strings as relative paths. External URLs (`http://`, `https://`, `//`, `mailto:`, `tel:`) now bypass href resolution. Internal hrefs are normalized to their canonical pre-basename form in `useDefinition`, so downstream resolution by react-router's `useHref` (for rendering) and `navigate` (for click-navigation) adds the basename exactly once. Signed-off-by: Johan Persson --- .../preserve-external-hrefs-useDefinition.md | 7 ++++ packages/ui/src/components/Link/Link.tsx | 3 ++ .../src/hooks/useDefinition/useDefinition.tsx | 34 +++++++++++---- packages/ui/src/hooks/useResolvedHref.ts | 42 +++++++++++++++++++ packages/ui/src/provider/BUIProvider.tsx | 5 ++- 5 files changed, 80 insertions(+), 11 deletions(-) create mode 100644 .changeset/preserve-external-hrefs-useDefinition.md create mode 100644 packages/ui/src/hooks/useResolvedHref.ts diff --git a/.changeset/preserve-external-hrefs-useDefinition.md b/.changeset/preserve-external-hrefs-useDefinition.md new file mode 100644 index 0000000000..b33e5db3ef --- /dev/null +++ b/.changeset/preserve-external-hrefs-useDefinition.md @@ -0,0 +1,7 @@ +--- +'@backstage/ui': patch +--- + +Fixed external URLs in BUI link components being rewritten as in-app paths when the app is served under a non-root base path. Absolute URLs (`http://`, `https://`, `//`, `mailto:`, `tel:`) are now passed through unchanged. Internal `href` values are resolved against the current `basename` exactly once, which also fixes a latent issue where internal link clicks under a non-root base path could navigate to a URL with the `basename` prefix doubled. + +**Affected components:** ButtonLink, Card, Link, Menu, Tab, Table, Tag diff --git a/packages/ui/src/components/Link/Link.tsx b/packages/ui/src/components/Link/Link.tsx index fca4843753..a66506deb0 100644 --- a/packages/ui/src/components/Link/Link.tsx +++ b/packages/ui/src/components/Link/Link.tsx @@ -18,6 +18,7 @@ import { forwardRef, useRef } from 'react'; import { useLink } from 'react-aria'; import type { LinkProps } from './types'; import { useDefinition } from '../../hooks/useDefinition'; +import { useResolvedHref } from '../../hooks/useResolvedHref'; import { LinkDefinition } from './definition'; import { getNodeText } from '../../analytics/getNodeText'; @@ -32,6 +33,7 @@ const LinkInternal = forwardRef((props, ref) => { const linkRef = (ref || internalRef) as React.RefObject; const { linkProps } = useLink(restProps, linkRef); + const resolvedHref = useResolvedHref(restProps.href); const handleClick = (e: React.MouseEvent) => { linkProps.onClick?.(e); @@ -49,6 +51,7 @@ const LinkInternal = forwardRef((props, ref) => { {...linkProps} {...dataAttributes} {...(restProps as React.AnchorHTMLAttributes)} + href={resolvedHref} ref={linkRef} title={title} className={classes.root} diff --git a/packages/ui/src/hooks/useDefinition/useDefinition.tsx b/packages/ui/src/hooks/useDefinition/useDefinition.tsx index a791343c15..a6faf58d1f 100644 --- a/packages/ui/src/hooks/useDefinition/useDefinition.tsx +++ b/packages/ui/src/hooks/useDefinition/useDefinition.tsx @@ -21,7 +21,8 @@ import { useBgProvider, useBgConsumer, BgProvider } from '../useBg'; import { resolveDefinitionProps, processUtilityProps } from './helpers'; import { useAnalytics } from '../../analytics/useAnalytics'; import { noopTracker } from '../../analytics/useAnalytics'; -import { useInRouterContext, useHref } from 'react-router-dom'; +import { useHref, useInRouterContext } from 'react-router-dom'; +import { isExternalLink } from '../../utils/linkUtils'; import type { ComponentConfig, UseDefinitionOptions, @@ -39,17 +40,32 @@ export function useDefinition< ): UseDefinitionResult { const { breakpoint } = useBreakpoint(); - // Turn relative href into an absolute path using the current route - // context, so that client-side navigation works correctly. + // Pre-resolve href at component render time (where route context is + // correct), so that click-navigation has a correct absolute path + // regardless of where useNavigate is called. `useHref` returns the + // path with basename prepended; strip it so the output is the + // canonical pre-basename form that react-router's downstream useHref + // and navigate both expect as input (avoids double-prefixing). + // External URLs bypass resolution. let hrefResolvedProps = props; const hasRouter = useInRouterContext(); - // useHref throws outside a Router, so we guard with useInRouterContext. - // The guard is safe because a component's router context does not - // change during its lifetime, keeping the hook call count stable. if (hasRouter) { - const absoluteHref = useHref((props as any).href ?? ''); - if ((props as any).href !== undefined) { - hrefResolvedProps = { ...props, href: absoluteHref } as P; + const rawHref = (props as any).href; + // useHref('/') returns the router's basename. Strip trailing slashes + // so the prefix check works regardless of how the consumer configured + // their . + const basename = useHref('/').replace(/\/+$/, '') || '/'; + const absoluteHref = useHref(rawHref ?? ''); + if (rawHref !== undefined && !isExternalLink(rawHref)) { + let stripped = absoluteHref; + if ( + basename !== '/' && + (absoluteHref === basename || absoluteHref.startsWith(`${basename}/`)) + ) { + stripped = + absoluteHref === basename ? '/' : absoluteHref.slice(basename.length); + } + hrefResolvedProps = { ...props, href: stripped } as P; } } diff --git a/packages/ui/src/hooks/useResolvedHref.ts b/packages/ui/src/hooks/useResolvedHref.ts new file mode 100644 index 0000000000..3e5075ed1d --- /dev/null +++ b/packages/ui/src/hooks/useResolvedHref.ts @@ -0,0 +1,42 @@ +/* + * Copyright 2026 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { useHref, useInRouterContext } from 'react-router-dom'; +import { isExternalLink } from '../utils/linkUtils'; + +/** + * Resolves an href for rendering. External URLs are returned unchanged; + * internal paths are resolved through react-router's useHref so they + * respect the current basename and route context. + * + * @internal + */ +export function useResolvedHref(href: string): string; +export function useResolvedHref(href: string | undefined): string | undefined; +export function useResolvedHref(href: string | undefined): string | undefined { + const hasRouter = useInRouterContext(); + // useHref throws outside a Router, so we guard with useInRouterContext. + // The guard is safe because a component's router context does not + // change during its lifetime, keeping the hook call count stable. + if (!hasRouter) { + return href; + } + const resolved = useHref(href ?? ''); + if (!href || isExternalLink(href)) { + return href; + } + return resolved; +} diff --git a/packages/ui/src/provider/BUIProvider.tsx b/packages/ui/src/provider/BUIProvider.tsx index 6576a9eabb..cc1d79774d 100644 --- a/packages/ui/src/provider/BUIProvider.tsx +++ b/packages/ui/src/provider/BUIProvider.tsx @@ -16,9 +16,10 @@ import { useMemo, type ReactNode } from 'react'; import { RouterProvider } from 'react-aria-components'; -import { useInRouterContext, useNavigate, useHref } from 'react-router-dom'; +import { useInRouterContext, useNavigate } from 'react-router-dom'; import { createVersionedValueMap } from '@backstage/version-bridge'; import { BUIContext } from '../analytics/useAnalytics'; +import { useResolvedHref } from '../hooks/useResolvedHref'; import type { UseAnalyticsFn } from '../analytics/types'; /** @public */ @@ -70,7 +71,7 @@ export function BUIProvider(props: BUIProviderProps) { function RoutedContent({ children }: { children: ReactNode }) { const navigate = useNavigate(); return ( - + {children} ); From 2deaa491205e4bcdc9ca0757eda13f37e27d4d39 Mon Sep 17 00:00:00 2001 From: Charles de Dreuille Date: Mon, 20 Apr 2026 17:59:45 +0100 Subject: [PATCH 063/434] fix(ui): replace react-markdown with inline parser to fix ESM Jest failures react-markdown v8+ is ESM-only and breaks Jest in Node-role packages that transitively import @backstage/ui via core-app-api. Since the Header description only needs inline link support, a small regex-based parser is sufficient and avoids the ESM dependency entirely. Signed-off-by: Charles de Dreuille --- packages/ui/package.json | 1 - packages/ui/src/components/Header/Header.tsx | 57 +++++++++++++------- yarn.lock | 1 - 3 files changed, 39 insertions(+), 20 deletions(-) diff --git a/packages/ui/package.json b/packages/ui/package.json index 242d15036c..1b17bc8744 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -52,7 +52,6 @@ "clsx": "^2.1.1", "react-aria": "~3.48.0", "react-aria-components": "~1.17.0", - "react-markdown": "^8.0.0", "react-stately": "~3.46.0", "use-sync-external-store": "^1.4.0" }, diff --git a/packages/ui/src/components/Header/Header.tsx b/packages/ui/src/components/Header/Header.tsx index 2518a6716d..9bd8bcdeab 100644 --- a/packages/ui/src/components/Header/Header.tsx +++ b/packages/ui/src/components/Header/Header.tsx @@ -22,7 +22,40 @@ import { useDefinition } from '../../hooks/useDefinition'; import { HeaderDefinition } from './definition'; import { Link } from '../Link'; import { Fragment } from 'react/jsx-runtime'; -import ReactMarkdown from 'react-markdown'; + +const INLINE_LINK_RE = /\[([^\]]+)\]\(([^)]+)\)/g; + +/** + * Renders a plain-text string that may contain inline Markdown links of the + * form `[label](href)` as an array of React nodes (strings and Link elements). + * + * We intentionally avoid `react-markdown` here: that package is ESM-only + * (v8+), which breaks Jest in Node-role packages that transitively import + * `@backstage/ui` (e.g. via `core-app-api`). Since the Header description only + * needs inline link support, a small regex-based parser is sufficient and keeps + * this package free of ESM dependencies. + */ +function renderInlineMarkdown(text: string): React.ReactNode[] { + const parts: React.ReactNode[] = []; + let last = 0; + let match: RegExpExecArray | null; + INLINE_LINK_RE.lastIndex = 0; + while ((match = INLINE_LINK_RE.exec(text)) !== null) { + if (match.index > last) { + parts.push(text.slice(last, match.index)); + } + parts.push( + + {match[1]} + , + ); + last = match.index + match[0].length; + } + if (last < text.length) { + parts.push(text.slice(last)); + } + return parts; +} /** * A secondary header with title, breadcrumbs, tabs, and actions. @@ -94,25 +127,13 @@ export const Header = (props: HeaderProps) => {
{customActions}
{description && ( - ( - - {children} - - ), - a: ({ href, children }) => ( - - {children} - - ), - }} > - {description} - + {renderInlineMarkdown(description)} + )} {metadata && metadata.length > 0 && (
diff --git a/yarn.lock b/yarn.lock index 7726a28c2a..6802845bac 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7987,7 +7987,6 @@ __metadata: react-aria: "npm:~3.48.0" react-aria-components: "npm:~1.17.0" react-dom: "npm:^18.0.2" - react-markdown: "npm:^8.0.0" react-router-dom: "npm:^6.30.2" react-stately: "npm:~3.46.0" storybook: "npm:^10.3.3" From 5de4a10b30c33d897875f236ac541adf2462b012 Mon Sep 17 00:00:00 2001 From: Charles de Dreuille Date: Mon, 20 Apr 2026 19:49:05 +0100 Subject: [PATCH 064/434] chore: update scaffolder API reports Signed-off-by: Charles de Dreuille --- plugins/scaffolder/report-alpha.api.md | 4 ++-- plugins/scaffolder/report.api.md | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/plugins/scaffolder/report-alpha.api.md b/plugins/scaffolder/report-alpha.api.md index 45e52f5bcc..10066f3fd3 100644 --- a/plugins/scaffolder/report-alpha.api.md +++ b/plugins/scaffolder/report-alpha.api.md @@ -775,13 +775,13 @@ export const scaffolderTranslationRef: TranslationRef< readonly 'actionsPage.action.examples': 'Examples'; readonly 'actionsPage.subtitle': 'This is the collection of all installed actions'; readonly 'actionsPage.pageTitle': 'Create a New Component'; + readonly 'listTaskPage.content.emptyState.title': 'No information to display'; + readonly 'listTaskPage.content.emptyState.description': 'There are no tasks or there was an issue communicating with backend.'; readonly 'listTaskPage.content.tableCell.template': 'Template'; readonly 'listTaskPage.content.tableCell.status': 'Status'; readonly 'listTaskPage.content.tableCell.owner': 'Owner'; readonly 'listTaskPage.content.tableCell.created': 'Created'; readonly 'listTaskPage.content.tableCell.taskID': 'Task ID'; - readonly 'listTaskPage.content.emptyState.title': 'No information to display'; - readonly 'listTaskPage.content.emptyState.description': 'There are no tasks or there was an issue communicating with backend.'; readonly 'listTaskPage.content.tableTitle': 'Tasks'; readonly 'listTaskPage.title': 'List template tasks'; readonly 'listTaskPage.subtitle': 'All tasks that have been started'; diff --git a/plugins/scaffolder/report.api.md b/plugins/scaffolder/report.api.md index 0d2756131d..a0c35e1b5e 100644 --- a/plugins/scaffolder/report.api.md +++ b/plugins/scaffolder/report.api.md @@ -673,13 +673,13 @@ export const scaffolderTranslationRef: TranslationRef< readonly 'actionsPage.action.examples': 'Examples'; readonly 'actionsPage.subtitle': 'This is the collection of all installed actions'; readonly 'actionsPage.pageTitle': 'Create a New Component'; + readonly 'listTaskPage.content.emptyState.title': 'No information to display'; + readonly 'listTaskPage.content.emptyState.description': 'There are no tasks or there was an issue communicating with backend.'; readonly 'listTaskPage.content.tableCell.template': 'Template'; readonly 'listTaskPage.content.tableCell.status': 'Status'; readonly 'listTaskPage.content.tableCell.owner': 'Owner'; readonly 'listTaskPage.content.tableCell.created': 'Created'; readonly 'listTaskPage.content.tableCell.taskID': 'Task ID'; - readonly 'listTaskPage.content.emptyState.title': 'No information to display'; - readonly 'listTaskPage.content.emptyState.description': 'There are no tasks or there was an issue communicating with backend.'; readonly 'listTaskPage.content.tableTitle': 'Tasks'; readonly 'listTaskPage.title': 'List template tasks'; readonly 'listTaskPage.subtitle': 'All tasks that have been started'; From a2e0636c1f53010352b8476303455c1f497159f9 Mon Sep 17 00:00:00 2001 From: Charles de Dreuille Date: Mon, 20 Apr 2026 21:22:20 +0100 Subject: [PATCH 065/434] fix(ui): address PR review comments on Header components - Guard against unsafe URL schemes (javascript:/vbscript:/data:) in description links - Use index-based keys for tags and metadata to avoid duplicate-key warnings - Render ReactNode metadata values directly instead of wrapping in Text to avoid invalid span>div nesting; only wrap plain strings in Text - Replace empty-string Avatar src fallback with data:, to prevent spurious page requests - Fix JSDoc in HeaderMetadataUsers to accurately describe the row layout Signed-off-by: Charles de Dreuille --- packages/ui/src/components/Header/Header.tsx | 32 +++++++++++++------ .../components/Header/HeaderMetadataUsers.tsx | 8 ++--- 2 files changed, 27 insertions(+), 13 deletions(-) diff --git a/packages/ui/src/components/Header/Header.tsx b/packages/ui/src/components/Header/Header.tsx index 9bd8bcdeab..67be3eb6bd 100644 --- a/packages/ui/src/components/Header/Header.tsx +++ b/packages/ui/src/components/Header/Header.tsx @@ -24,10 +24,14 @@ import { Link } from '../Link'; import { Fragment } from 'react/jsx-runtime'; const INLINE_LINK_RE = /\[([^\]]+)\]\(([^)]+)\)/g; +// Reject javascript:/vbscript:/data: URIs to prevent XSS via description links. +const UNSAFE_HREF_RE = /^(javascript:|vbscript:|data:)/i; /** * Renders a plain-text string that may contain inline Markdown links of the * form `[label](href)` as an array of React nodes (strings and Link elements). + * Links with unsafe URL schemes (javascript:, vbscript:, data:) are rendered + * as plain text instead. * * We intentionally avoid `react-markdown` here: that package is ESM-only * (v8+), which breaks Jest in Node-role packages that transitively import @@ -44,11 +48,17 @@ function renderInlineMarkdown(text: string): React.ReactNode[] { if (match.index > last) { parts.push(text.slice(last, match.index)); } - parts.push( - - {match[1]} - , - ); + const href = match[2]; + const label = match[1]; + if (UNSAFE_HREF_RE.test(href)) { + parts.push(label); + } else { + parts.push( + + {label} + , + ); + } last = match.index + match[0].length; } if (last < text.length) { @@ -81,7 +91,7 @@ export const Header = (props: HeaderProps) => { {tags && tags.length > 0 && (
{tags.map((tag, i) => ( - + {i > 0 && } {tag.href ? ( { )} {metadata && metadata.length > 0 && (
- {metadata.map(item => ( -
+ {metadata.map((item, i) => ( +
{item.label} - {item.value} + {typeof item.value === 'string' ? ( + {item.value} + ) : ( + item.value + )}
))}
diff --git a/packages/ui/src/components/Header/HeaderMetadataUsers.tsx b/packages/ui/src/components/Header/HeaderMetadataUsers.tsx index 3f68398ca5..e644811899 100644 --- a/packages/ui/src/components/Header/HeaderMetadataUsers.tsx +++ b/packages/ui/src/components/Header/HeaderMetadataUsers.tsx @@ -25,7 +25,7 @@ import styles from './HeaderMetadataUsers.module.css'; /** * Displays a list of users as avatars inside a Header metadata value. * A single user shows the avatar with their name beside it. - * Multiple users show overlapping avatars with the name revealed on hover via tooltip. + * Multiple users show avatars in a row with the name revealed on hover via tooltip. * When a user has an `href`, the avatar and name become links. * * @public @@ -41,7 +41,7 @@ export const HeaderMetadataUsers = ({ const user = users[0]; const avatar = ( Date: Tue, 21 Apr 2026 08:55:53 +0100 Subject: [PATCH 066/434] revert(ui): restore Container wrapper on Header Reverting the full-width change as it is too disruptive at this time and will be handled in a separate PR. Signed-off-by: Charles de Dreuille --- packages/ui/src/components/Header/Header.stories.tsx | 3 +++ packages/ui/src/components/Header/Header.tsx | 5 +++-- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/ui/src/components/Header/Header.stories.tsx b/packages/ui/src/components/Header/Header.stories.tsx index 77994ab4d9..59e5cf254d 100644 --- a/packages/ui/src/components/Header/Header.stories.tsx +++ b/packages/ui/src/components/Header/Header.stories.tsx @@ -28,6 +28,9 @@ import { RiMore2Line } from '@remixicon/react'; const meta = preview.meta({ title: 'Backstage UI/Header', component: Header, + parameters: { + layout: 'fullscreen', + }, }); const tabs: HeaderNavTabItem[] = [ diff --git a/packages/ui/src/components/Header/Header.tsx b/packages/ui/src/components/Header/Header.tsx index 67be3eb6bd..f70423fdd6 100644 --- a/packages/ui/src/components/Header/Header.tsx +++ b/packages/ui/src/components/Header/Header.tsx @@ -20,6 +20,7 @@ import { RiArrowRightSLine } from '@remixicon/react'; import { HeaderNav } from './HeaderNav'; import { useDefinition } from '../../hooks/useDefinition'; import { HeaderDefinition } from './definition'; +import { Container } from '../Container'; import { Link } from '../Link'; import { Fragment } from 'react/jsx-runtime'; @@ -87,7 +88,7 @@ export const Header = (props: HeaderProps) => { } = ownProps; return ( -
+ {tags && tags.length > 0 && (
{tags.map((tag, i) => ( @@ -166,7 +167,7 @@ export const Header = (props: HeaderProps) => {
)} -
+ ); }; From ea11646d8df7f612c79cfa4dddc588d9cdf7f48f Mon Sep 17 00:00:00 2001 From: Charles de Dreuille Date: Tue, 21 Apr 2026 09:34:34 +0100 Subject: [PATCH 067/434] fix(ui): address second round of PR review comments - Replace hardcoded gap: 20px with var(--bui-space-5) in metadata row - Trim leading whitespace from href before unsafe-scheme check to prevent bypass Signed-off-by: Charles de Dreuille --- packages/ui/src/components/Header/Header.module.css | 2 +- packages/ui/src/components/Header/Header.tsx | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/ui/src/components/Header/Header.module.css b/packages/ui/src/components/Header/Header.module.css index ccceaf6f58..e6ea55e363 100644 --- a/packages/ui/src/components/Header/Header.module.css +++ b/packages/ui/src/components/Header/Header.module.css @@ -68,7 +68,7 @@ display: flex; flex-direction: row; align-items: center; - gap: 20px; + gap: var(--bui-space-5); flex-wrap: wrap; } diff --git a/packages/ui/src/components/Header/Header.tsx b/packages/ui/src/components/Header/Header.tsx index f70423fdd6..90e107c51c 100644 --- a/packages/ui/src/components/Header/Header.tsx +++ b/packages/ui/src/components/Header/Header.tsx @@ -49,7 +49,9 @@ function renderInlineMarkdown(text: string): React.ReactNode[] { if (match.index > last) { parts.push(text.slice(last, match.index)); } - const href = match[2]; + // Trim leading whitespace/control chars before scheme check to prevent + // bypass via inputs like " javascript:alert(1)". + const href = match[2].trimStart(); const label = match[1]; if (UNSAFE_HREF_RE.test(href)) { parts.push(label); From ffb749e4b66ff5ec5ce9de1b44e0997ae6ab53cd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20Adel=C3=B6w?= Date: Tue, 21 Apr 2026 16:21:03 +0200 Subject: [PATCH 068/434] docs(swappable-components): fix missing backticks and small typos MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.7 (1M context) Signed-off-by: Fredrik Adelöw --- .../06-swappable-components.md | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/docs/frontend-system/building-plugins/06-swappable-components.md b/docs/frontend-system/building-plugins/06-swappable-components.md index 786388b3c8..a6ed423852 100644 --- a/docs/frontend-system/building-plugins/06-swappable-components.md +++ b/docs/frontend-system/building-plugins/06-swappable-components.md @@ -8,11 +8,11 @@ description: Configuring or overriding Swappable Components # Swappable components Swappable components are a feature of the frontend system that allow you to replace the implementations of components that are used in your Backstage app. -These Swappable Components are defined using `createSwappableComponent` and then can be exported from a plugins `-react` package in order to be used in both other plugins, and to be rebound to a new implementation by the Backstage Integrator. +These Swappable Components are defined using `createSwappableComponent` and then can be exported from a plugin's `-react` package in order to be used in both other plugins, and to be rebound to a new implementation by the Backstage Integrator. ## Creating a Swappable Component -In order to create a Swappable Component, you need to use the `createSwappableComponent` function from the `@backstage/frontend-plugin-api` package. You can supply a default implementation for the component, as well as a way to separate both the props of the external component and in the implementation of the component. +In order to create a Swappable Component, you need to use the `createSwappableComponent` function from the `@backstage/frontend-plugin-api` package. You can supply a default implementation for the component, as well as a way to separate the props of the external component from the props used by the implementation of the component. ```tsx title="in @internal/plugin-example-react" import { createSwappableComponent } from '@backstage/frontend-plugin-api'; @@ -20,9 +20,9 @@ import { createSwappableComponent } from '@backstage/frontend-plugin-api'; export const ExampleSwappableComponent = createSwappableComponent({ name: 'example', - // This is a loader for loading the default implementation of the component when there's no overriden + // This is a loader for loading the default implementation of the component when there's no overridden // implementation created with `SwappableComponentBlueprint`. - // It can be sync like below, but is can also be async like `loader: () => import('./DefaultImplementation').then(m => m.DefaultImplementation)`. + // It can be sync like below, but it can also be async like `loader: () => import('./DefaultImplementation').then(m => m.DefaultImplementation)`. loader: () => (props: { name: string }) =>
Your name is {props.name}
, @@ -60,7 +60,7 @@ import appPlugin from '@backstage/plugin-app'; const app = createApp({ features: [ - // Using a module to provide the extenion to the app + // Using a module to provide the extension to the app createFrontendModule({ pluginId: 'app', extensions: [ @@ -74,7 +74,7 @@ const app = createApp({ }), ], }), - // Core components that already ship with the app plugin can be overriden using getExtension() + // Core components that already ship with the app plugin can be overridden using getExtension() appPlugin.withOverrides({ extensions: [ appPlugin.getExtension('component:app/core-progress').override({ @@ -94,9 +94,9 @@ const app = createApp({ Currently there are only three different built-in Swappable Components that you can replace the implementations of, and these live in `@backstage/frontend-plugin-api`. They are as follows: -- ` -- ` -- ` +- `` +- `` +- `` You can see more about these components at their [definition](https://github.com/backstage/backstage/blob/master/packages/frontend-plugin-api/src/components/DefaultSwappableComponents.tsx), and their default implementations are shipped inside the [`app-plugin`](https://github.com/backstage/backstage/blob/master/plugins/app/src/extensions/components.tsx). From 8c8199cdf9c0256015c73a976db81b7e0219e969 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20Adel=C3=B6w?= Date: Tue, 21 Apr 2026 16:29:15 +0200 Subject: [PATCH 069/434] Update docs/frontend-system/building-plugins/06-swappable-components.md MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Signed-off-by: Fredrik Adelöw --- .../frontend-system/building-plugins/06-swappable-components.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/frontend-system/building-plugins/06-swappable-components.md b/docs/frontend-system/building-plugins/06-swappable-components.md index a6ed423852..7978ba667b 100644 --- a/docs/frontend-system/building-plugins/06-swappable-components.md +++ b/docs/frontend-system/building-plugins/06-swappable-components.md @@ -20,7 +20,7 @@ import { createSwappableComponent } from '@backstage/frontend-plugin-api'; export const ExampleSwappableComponent = createSwappableComponent({ name: 'example', - // This is a loader for loading the default implementation of the component when there's no overridden + // This is a loader for loading the default implementation of the component when there's no overriding // implementation created with `SwappableComponentBlueprint`. // It can be sync like below, but it can also be async like `loader: () => import('./DefaultImplementation').then(m => m.DefaultImplementation)`. loader: () => (props: { name: string }) => From 03311e33da9a8bb75d6b27efe4f816972b5bbb2f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20Adel=C3=B6w?= Date: Tue, 21 Apr 2026 16:35:29 +0200 Subject: [PATCH 070/434] Make NotificationDescription a swappable component MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.7 (1M context) Signed-off-by: Fredrik Adelöw --- .../swappable-notification-description.md | 5 ++++ plugins/notifications/report.api.md | 16 +++++++++++ .../NotificationDescription.tsx | 27 ++++++++++++++++++- .../components/NotificationsTable/index.ts | 1 + 4 files changed, 48 insertions(+), 1 deletion(-) create mode 100644 .changeset/swappable-notification-description.md diff --git a/.changeset/swappable-notification-description.md b/.changeset/swappable-notification-description.md new file mode 100644 index 0000000000..abfa470035 --- /dev/null +++ b/.changeset/swappable-notification-description.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-notifications': patch +--- + +The notification description used in the notifications table is now a swappable component, so that apps can replace its rendering with a custom implementation. diff --git a/plugins/notifications/report.api.md b/plugins/notifications/report.api.md index ff39a03a8a..74dd1b137b 100644 --- a/plugins/notifications/report.api.md +++ b/plugins/notifications/report.api.md @@ -14,6 +14,7 @@ import { NotificationSettings } from '@backstage/plugin-notifications-common'; import { NotificationSeverity } from '@backstage/plugin-notifications-common'; import { NotificationStatus } from '@backstage/plugin-notifications-common'; import { RouteRef } from '@backstage/core-plugin-api'; +import { SwappableComponentRef } from '@backstage/frontend-plugin-api'; import { TableProps } from '@backstage/core-components'; import { TranslationRef } from '@backstage/frontend-plugin-api'; @@ -49,6 +50,21 @@ export type GetTopicsResponse = { topics: string[]; }; +// @public +export const NotificationDescription: { + (props: NotificationDescriptionProps): JSX.Element | null; + ref: SwappableComponentRef< + NotificationDescriptionProps, + NotificationDescriptionProps + >; +}; + +// @public +export interface NotificationDescriptionProps { + // (undocumented) + description: string; +} + // @public (undocumented) export interface NotificationsApi { // (undocumented) diff --git a/plugins/notifications/src/components/NotificationsTable/NotificationDescription.tsx b/plugins/notifications/src/components/NotificationsTable/NotificationDescription.tsx index 760473d064..dc023031a3 100644 --- a/plugins/notifications/src/components/NotificationsTable/NotificationDescription.tsx +++ b/plugins/notifications/src/components/NotificationsTable/NotificationDescription.tsx @@ -15,10 +15,22 @@ */ import { Text, Button } from '@backstage/ui'; import { useState } from 'react'; +import { createSwappableComponent } from '@backstage/frontend-plugin-api'; + +/** + * Props for the {@link NotificationDescription} swappable component. + * + * @public + */ +export interface NotificationDescriptionProps { + description: string; +} const MAX_LENGTH = 100; -export const NotificationDescription = (props: { description: string }) => { +const DefaultNotificationDescription = ( + props: NotificationDescriptionProps, +) => { const { description } = props; const [shown, setShown] = useState(false); const isLong = description.length > MAX_LENGTH; @@ -56,3 +68,16 @@ export const NotificationDescription = (props: { description: string }) => { ); }; + +/** + * Swappable component that renders the description of a notification in the + * notifications table. Apps can override this to customize how descriptions + * are displayed. + * + * @public + */ +export const NotificationDescription = + createSwappableComponent({ + id: 'notifications.notification-description', + loader: () => DefaultNotificationDescription, + }); diff --git a/plugins/notifications/src/components/NotificationsTable/index.ts b/plugins/notifications/src/components/NotificationsTable/index.ts index a1fb7a25c6..81d6cacce9 100644 --- a/plugins/notifications/src/components/NotificationsTable/index.ts +++ b/plugins/notifications/src/components/NotificationsTable/index.ts @@ -14,3 +14,4 @@ * limitations under the License. */ export * from './NotificationsTable'; +export * from './NotificationDescription'; From 4f7e5219de7895bb9bdef3c437a1455e036b4d40 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20Adel=C3=B6w?= Date: Tue, 21 Apr 2026 16:44:48 +0200 Subject: [PATCH 071/434] Load the default NotificationDescription implementation lazily MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.7 (1M context) Signed-off-by: Fredrik Adelöw --- .../DefaultNotificationDescription.tsx | 61 +++++++++++++++++++ .../NotificationDescription.tsx | 50 ++------------- 2 files changed, 65 insertions(+), 46 deletions(-) create mode 100644 plugins/notifications/src/components/NotificationsTable/DefaultNotificationDescription.tsx diff --git a/plugins/notifications/src/components/NotificationsTable/DefaultNotificationDescription.tsx b/plugins/notifications/src/components/NotificationsTable/DefaultNotificationDescription.tsx new file mode 100644 index 0000000000..d2f93038ee --- /dev/null +++ b/plugins/notifications/src/components/NotificationsTable/DefaultNotificationDescription.tsx @@ -0,0 +1,61 @@ +/* + * Copyright 2025 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { Text, Button } from '@backstage/ui'; +import { useState } from 'react'; +import { NotificationDescriptionProps } from './NotificationDescription'; + +const MAX_LENGTH = 100; + +export const DefaultNotificationDescription = ( + props: NotificationDescriptionProps, +) => { + const { description } = props; + const [shown, setShown] = useState(false); + const isLong = description.length > MAX_LENGTH; + + if (!isLong) { + return {description}; + } + + if (shown) { + return ( + + {description}{' '} + + + ); + } + return ( + + {description.substring(0, MAX_LENGTH)}...{' '} + + + ); +}; diff --git a/plugins/notifications/src/components/NotificationsTable/NotificationDescription.tsx b/plugins/notifications/src/components/NotificationsTable/NotificationDescription.tsx index dc023031a3..a88d62b9c5 100644 --- a/plugins/notifications/src/components/NotificationsTable/NotificationDescription.tsx +++ b/plugins/notifications/src/components/NotificationsTable/NotificationDescription.tsx @@ -13,8 +13,6 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { Text, Button } from '@backstage/ui'; -import { useState } from 'react'; import { createSwappableComponent } from '@backstage/frontend-plugin-api'; /** @@ -26,49 +24,6 @@ export interface NotificationDescriptionProps { description: string; } -const MAX_LENGTH = 100; - -const DefaultNotificationDescription = ( - props: NotificationDescriptionProps, -) => { - const { description } = props; - const [shown, setShown] = useState(false); - const isLong = description.length > MAX_LENGTH; - - if (!isLong) { - return {description}; - } - - if (shown) { - return ( - - {description}{' '} - - - ); - } - return ( - - {description.substring(0, MAX_LENGTH)}...{' '} - - - ); -}; - /** * Swappable component that renders the description of a notification in the * notifications table. Apps can override this to customize how descriptions @@ -79,5 +34,8 @@ const DefaultNotificationDescription = ( export const NotificationDescription = createSwappableComponent({ id: 'notifications.notification-description', - loader: () => DefaultNotificationDescription, + loader: () => + import('./DefaultNotificationDescription').then( + m => m.DefaultNotificationDescription, + ), }); From de9fc68cf12e20e864ca504b0dc83e330adeb286 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20Adel=C3=B6w?= Date: Tue, 21 Apr 2026 17:10:29 +0200 Subject: [PATCH 072/434] Document the description prop on NotificationDescription MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.7 (1M context) Signed-off-by: Fredrik Adelöw --- plugins/notifications/report.api.md | 1 - .../components/NotificationsTable/NotificationDescription.tsx | 3 +++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/plugins/notifications/report.api.md b/plugins/notifications/report.api.md index 74dd1b137b..627ea51e82 100644 --- a/plugins/notifications/report.api.md +++ b/plugins/notifications/report.api.md @@ -61,7 +61,6 @@ export const NotificationDescription: { // @public export interface NotificationDescriptionProps { - // (undocumented) description: string; } diff --git a/plugins/notifications/src/components/NotificationsTable/NotificationDescription.tsx b/plugins/notifications/src/components/NotificationsTable/NotificationDescription.tsx index a88d62b9c5..c284e1e85a 100644 --- a/plugins/notifications/src/components/NotificationsTable/NotificationDescription.tsx +++ b/plugins/notifications/src/components/NotificationsTable/NotificationDescription.tsx @@ -21,6 +21,9 @@ import { createSwappableComponent } from '@backstage/frontend-plugin-api'; * @public */ export interface NotificationDescriptionProps { + /** + * The plain-text description of the notification. + */ description: string; } From 94c1cf55c7fbfdc26c61dca7f50aff7b1b1fb8da Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20Adel=C3=B6w?= Date: Tue, 21 Apr 2026 18:14:54 +0200 Subject: [PATCH 073/434] Use a type-only import for NotificationDescriptionProps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.7 (1M context) Signed-off-by: Fredrik Adelöw --- .../NotificationsTable/DefaultNotificationDescription.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/notifications/src/components/NotificationsTable/DefaultNotificationDescription.tsx b/plugins/notifications/src/components/NotificationsTable/DefaultNotificationDescription.tsx index d2f93038ee..9524937380 100644 --- a/plugins/notifications/src/components/NotificationsTable/DefaultNotificationDescription.tsx +++ b/plugins/notifications/src/components/NotificationsTable/DefaultNotificationDescription.tsx @@ -15,7 +15,7 @@ */ import { Text, Button } from '@backstage/ui'; import { useState } from 'react'; -import { NotificationDescriptionProps } from './NotificationDescription'; +import type { NotificationDescriptionProps } from './NotificationDescription'; const MAX_LENGTH = 100; From 20f0689b290f246038a2b3a6b38ace1ce82da266 Mon Sep 17 00:00:00 2001 From: Johan Persson Date: Wed, 22 Apr 2026 09:03:40 +0200 Subject: [PATCH 074/434] patches: add entry for #34004 Signed-off-by: Johan Persson --- .patches/pr-34004.txt | 1 + 1 file changed, 1 insertion(+) create mode 100644 .patches/pr-34004.txt diff --git a/.patches/pr-34004.txt b/.patches/pr-34004.txt new file mode 100644 index 0000000000..506fd76855 --- /dev/null +++ b/.patches/pr-34004.txt @@ -0,0 +1 @@ +Preserve external hrefs in BUI link components under non-root base path \ No newline at end of file From df8e8196e077a695514b65292739ad790c1b8550 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20Adel=C3=B6w?= Date: Wed, 22 Apr 2026 09:13:40 +0200 Subject: [PATCH 075/434] chore: remove .patches entries already released in v1.50.2 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The following patches were included in the v1.50.2 patch release and no longer need to be tracked: - pr-33908 (TechDocs sidebar positioning) - pr-33952 (zod v4 bump) - pr-33975 (React Aria dependency clamping) - pr-33984 (tab indicator opacity fix) Signed-off-by: Fredrik Adelöw Made-with: Cursor --- .patches/pr-33908.txt | 1 - .patches/pr-33952.txt | 1 - .patches/pr-33975.txt | 1 - .patches/pr-33984.txt | 1 - 4 files changed, 4 deletions(-) delete mode 100644 .patches/pr-33908.txt delete mode 100644 .patches/pr-33952.txt delete mode 100644 .patches/pr-33975.txt delete mode 100644 .patches/pr-33984.txt diff --git a/.patches/pr-33908.txt b/.patches/pr-33908.txt deleted file mode 100644 index 5d0dd14d5a..0000000000 --- a/.patches/pr-33908.txt +++ /dev/null @@ -1 +0,0 @@ -Make TechDocs sidebar positioning configurable via CSS custom properties \ No newline at end of file diff --git a/.patches/pr-33952.txt b/.patches/pr-33952.txt deleted file mode 100644 index 3244566d2e..0000000000 --- a/.patches/pr-33952.txt +++ /dev/null @@ -1 +0,0 @@ -Bump zod dependency to v4 for packages using configSchema and clarify that zod/v4 subpath from v3 is not supported \ No newline at end of file diff --git a/.patches/pr-33975.txt b/.patches/pr-33975.txt deleted file mode 100644 index 5cc55be263..0000000000 --- a/.patches/pr-33975.txt +++ /dev/null @@ -1 +0,0 @@ -Clamp React Aria dependency ranges to patch-only updates to prevent unintended minor version upgrades \ No newline at end of file diff --git a/.patches/pr-33984.txt b/.patches/pr-33984.txt deleted file mode 100644 index 522b8121d9..0000000000 --- a/.patches/pr-33984.txt +++ /dev/null @@ -1 +0,0 @@ -Fix active tab indicator disappearing on uncontrolled Tabs in @backstage/ui \ No newline at end of file From 504ebe49a0f3745fdbfdd3fd593199c7aff25e26 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20Adel=C3=B6w?= Date: Wed, 22 Apr 2026 10:27:27 +0200 Subject: [PATCH 076/434] Update .changeset/gold-friends-end.md MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Fredrik Adelöw --- .changeset/gold-friends-end.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/gold-friends-end.md b/.changeset/gold-friends-end.md index eddacadf19..ddbe4cee8a 100644 --- a/.changeset/gold-friends-end.md +++ b/.changeset/gold-friends-end.md @@ -1,5 +1,5 @@ --- -'@backstage/plugin-scaffolder-node': minor +'@backstage/plugin-scaffolder-node': patch --- Added optional `status` filter to `ScaffolderService.listTasks`, allowing callers to retrieve tasks matching a specific status. From ec109ce7fb1299180913469260b681bf9ca03144 Mon Sep 17 00:00:00 2001 From: Charles de Dreuille Date: Wed, 22 Apr 2026 12:41:26 +0100 Subject: [PATCH 077/434] fix(ui): replace custom regex with marked Lexer for inline description parsing Uses marked's Lexer.lexInline() instead of a hand-rolled regex to parse inline links in the Header description. marked ships CommonJS, has zero dependencies, and is already used in the monorepo. This gives us a proper token model that handles edge cases the regex could not. Signed-off-by: Charles de Dreuille --- packages/ui/package.json | 1 + packages/ui/src/components/Header/Header.tsx | 56 ++++++++------------ yarn.lock | 1 + 3 files changed, 23 insertions(+), 35 deletions(-) diff --git a/packages/ui/package.json b/packages/ui/package.json index 1b17bc8744..0c43476e6a 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -50,6 +50,7 @@ "@remixicon/react": "^4.6.0", "@tanstack/react-table": "^8.21.3", "clsx": "^2.1.1", + "marked": "^15.0.12", "react-aria": "~3.48.0", "react-aria-components": "~1.17.0", "react-stately": "~3.46.0", diff --git a/packages/ui/src/components/Header/Header.tsx b/packages/ui/src/components/Header/Header.tsx index 90e107c51c..0098fa00d4 100644 --- a/packages/ui/src/components/Header/Header.tsx +++ b/packages/ui/src/components/Header/Header.tsx @@ -21,53 +21,39 @@ import { HeaderNav } from './HeaderNav'; import { useDefinition } from '../../hooks/useDefinition'; import { HeaderDefinition } from './definition'; import { Container } from '../Container'; +import { Lexer } from 'marked'; import { Link } from '../Link'; import { Fragment } from 'react/jsx-runtime'; -const INLINE_LINK_RE = /\[([^\]]+)\]\(([^)]+)\)/g; // Reject javascript:/vbscript:/data: URIs to prevent XSS via description links. const UNSAFE_HREF_RE = /^(javascript:|vbscript:|data:)/i; /** - * Renders a plain-text string that may contain inline Markdown links of the - * form `[label](href)` as an array of React nodes (strings and Link elements). - * Links with unsafe URL schemes (javascript:, vbscript:, data:) are rendered - * as plain text instead. + * Renders a plain-text string that may contain inline Markdown links as an + * array of React nodes (strings and Link elements). Links with unsafe URL + * schemes (javascript:, vbscript:, data:) are rendered as plain text instead. * - * We intentionally avoid `react-markdown` here: that package is ESM-only - * (v8+), which breaks Jest in Node-role packages that transitively import - * `@backstage/ui` (e.g. via `core-app-api`). Since the Header description only - * needs inline link support, a small regex-based parser is sufficient and keeps - * this package free of ESM dependencies. + * We use `marked`'s `Lexer.lexInline()` rather than `react-markdown` because + * `react-markdown` v8+ is ESM-only, which breaks Jest in Node-role packages + * that transitively import `@backstage/ui` (e.g. via `core-app-api`). `marked` + * ships CommonJS, has zero dependencies, and its inline lexer gives us a clean + * token model without needing to maintain a custom regex. */ function renderInlineMarkdown(text: string): React.ReactNode[] { - const parts: React.ReactNode[] = []; - let last = 0; - let match: RegExpExecArray | null; - INLINE_LINK_RE.lastIndex = 0; - while ((match = INLINE_LINK_RE.exec(text)) !== null) { - if (match.index > last) { - parts.push(text.slice(last, match.index)); - } - // Trim leading whitespace/control chars before scheme check to prevent - // bypass via inputs like " javascript:alert(1)". - const href = match[2].trimStart(); - const label = match[1]; - if (UNSAFE_HREF_RE.test(href)) { - parts.push(label); - } else { - parts.push( - - {label} - , + return Lexer.lexInline(text).map((token, i) => { + if (token.type === 'link') { + // Trim leading whitespace/control chars before scheme check to prevent + // bypass via inputs like " javascript:alert(1)". + const href = token.href.trimStart(); + if (UNSAFE_HREF_RE.test(href)) return token.text; + return ( + + {token.text} + ); } - last = match.index + match[0].length; - } - if (last < text.length) { - parts.push(text.slice(last)); - } - return parts; + return token.raw; + }); } /** diff --git a/yarn.lock b/yarn.lock index 6802845bac..bbd007be7f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7983,6 +7983,7 @@ __metadata: eslint-plugin-storybook: "npm:^10.3.3" glob: "npm:^13.0.0" globals: "npm:^17.0.0" + marked: "npm:^15.0.12" react: "npm:^18.0.2" react-aria: "npm:~3.48.0" react-aria-components: "npm:~1.17.0" From a0ea7b3152b2a2d317fa5921d5793670b576fc05 Mon Sep 17 00:00:00 2001 From: Etienne Napoleone Date: Wed, 22 Apr 2026 14:10:02 +0200 Subject: [PATCH 078/434] fix(ui): disable card content scroll shadow on unsuported browsers bugged in firefox https://caniuse.com/mdn-css_properties_animation-timeline_scroll Signed-off-by: Etienne Napoleone --- packages/ui/src/components/Card/Card.module.css | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/packages/ui/src/components/Card/Card.module.css b/packages/ui/src/components/Card/Card.module.css index eced185550..39af87ccc0 100644 --- a/packages/ui/src/components/Card/Card.module.css +++ b/packages/ui/src/components/Card/Card.module.css @@ -111,8 +111,14 @@ .bui-Card:has(.bui-CardHeader) .bui-CardBody { padding-block-start: 0; + } - &::before { + .bui-Card:has(.bui-CardFooter) .bui-CardBody { + padding-block-end: 0; + } + + @supports (animation-timeline: scroll()) { + .bui-Card:has(.bui-CardHeader) .bui-CardBody::before { content: ''; position: sticky; top: 0; @@ -130,12 +136,8 @@ animation-timeline: scroll(); animation-range: 0px 2.5rem; } - } - .bui-Card:has(.bui-CardFooter) .bui-CardBody { - padding-block-end: 0; - - &::after { + .bui-Card:has(.bui-CardFooter) .bui-CardBody::after { content: ''; position: sticky; bottom: 0; From 6ce1444ca0610566b8e770381e8a3791948a239e Mon Sep 17 00:00:00 2001 From: Etienne Napoleone Date: Wed, 22 Apr 2026 14:28:42 +0200 Subject: [PATCH 079/434] chore(changeset): add card scroll shadow fix changeset Signed-off-by: Etienne Napoleone --- .changeset/fix-card-scroll-shadow | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 .changeset/fix-card-scroll-shadow diff --git a/.changeset/fix-card-scroll-shadow b/.changeset/fix-card-scroll-shadow new file mode 100644 index 0000000000..47816b9581 --- /dev/null +++ b/.changeset/fix-card-scroll-shadow @@ -0,0 +1,7 @@ +--- +'@backstage/ui': patch +--- + +Disabled `Card` scroll shadow on browser that doesn't support `animation-timeline: scroll()`. Prevents the shadow from being always visible over the `CardBody` when there's nothing to scroll or the body is not scrolled. + +**Affected components:** Card From a69c2e29a29f019b6876bee7837305e219c81680 Mon Sep 17 00:00:00 2001 From: Charles de Dreuille Date: Wed, 22 Apr 2026 15:02:28 +0100 Subject: [PATCH 080/434] fix(ui): improve semantic markup and a11y across Header components - Tags: replace div+Fragment with ul/li; move circle divider to CSS ::before pseudo-element, removing it from the DOM - Metadata: replace div wrapper with dl/dt/dd for proper key-value semantics; reset dl and dd browser margins - HeaderMetadataUsers: render multi-user stack as ul/li; simplify single-user branch into one ternary with a fragment - HeaderMetadataStatus: add role="img" and aria-label to the status dot so screen readers announce its meaning - Restore Fragment import from react Signed-off-by: Charles de Dreuille --- .../src/components/Header/Header.module.css | 17 +++- packages/ui/src/components/Header/Header.tsx | 38 ++++--- .../Header/HeaderMetadataStatus.tsx | 6 +- .../Header/HeaderMetadataUsers.module.css | 3 + .../components/Header/HeaderMetadataUsers.tsx | 99 ++++++++++--------- .../ui/src/components/Header/definition.ts | 2 +- 6 files changed, 99 insertions(+), 66 deletions(-) diff --git a/packages/ui/src/components/Header/Header.module.css b/packages/ui/src/components/Header/Header.module.css index e6ea55e363..2e3ca28d3b 100644 --- a/packages/ui/src/components/Header/Header.module.css +++ b/packages/ui/src/components/Header/Header.module.css @@ -54,9 +54,19 @@ align-items: center; gap: var(--bui-space-2); flex-wrap: wrap; + list-style: none; + margin: 0; + padding: 0; } - .bui-HeaderTagDivider { + .bui-HeaderTag { + display: flex; + align-items: center; + gap: var(--bui-space-2); + } + + .bui-HeaderTag + .bui-HeaderTag::before { + content: ''; width: 3px; height: 3px; border-radius: 50%; @@ -70,6 +80,7 @@ align-items: center; gap: var(--bui-space-5); flex-wrap: wrap; + margin: 0; } .bui-HeaderMetaItem { @@ -77,5 +88,9 @@ flex-direction: row; align-items: center; gap: var(--bui-space-2); + + dd { + margin: 0; + } } } diff --git a/packages/ui/src/components/Header/Header.tsx b/packages/ui/src/components/Header/Header.tsx index 0098fa00d4..5d41d6ca0f 100644 --- a/packages/ui/src/components/Header/Header.tsx +++ b/packages/ui/src/components/Header/Header.tsx @@ -23,7 +23,7 @@ import { HeaderDefinition } from './definition'; import { Container } from '../Container'; import { Lexer } from 'marked'; import { Link } from '../Link'; -import { Fragment } from 'react/jsx-runtime'; +import { Fragment } from 'react'; // Reject javascript:/vbscript:/data: URIs to prevent XSS via description links. const UNSAFE_HREF_RE = /^(javascript:|vbscript:|data:)/i; @@ -78,10 +78,12 @@ export const Header = (props: HeaderProps) => { return ( {tags && tags.length > 0 && ( -
+
    {tags.map((tag, i) => ( - - {i > 0 && } +
  • {tag.href ? ( { {tag.label} )} - +
  • ))} -
+ )}
@@ -135,20 +137,24 @@ export const Header = (props: HeaderProps) => { )} {metadata && metadata.length > 0 && ( -
+
{metadata.map((item, i) => (
- - {item.label} - - {typeof item.value === 'string' ? ( - {item.value} - ) : ( - item.value - )} +
+ + {item.label} + +
+
+ {typeof item.value === 'string' ? ( + {item.value} + ) : ( + item.value + )} +
))} -
+ )} {tabs && (
diff --git a/packages/ui/src/components/Header/HeaderMetadataStatus.tsx b/packages/ui/src/components/Header/HeaderMetadataStatus.tsx index 75c9ea778f..2bf2effa8f 100644 --- a/packages/ui/src/components/Header/HeaderMetadataStatus.tsx +++ b/packages/ui/src/components/Header/HeaderMetadataStatus.tsx @@ -32,7 +32,11 @@ export const HeaderMetadataStatus = ({ }: HeaderMetadataStatusItem) => { return (
- + {href ? ( diff --git a/packages/ui/src/components/Header/HeaderMetadataUsers.module.css b/packages/ui/src/components/Header/HeaderMetadataUsers.module.css index 745e768d56..0b84734b81 100644 --- a/packages/ui/src/components/Header/HeaderMetadataUsers.module.css +++ b/packages/ui/src/components/Header/HeaderMetadataUsers.module.css @@ -29,6 +29,9 @@ flex-direction: row; align-items: center; gap: var(--bui-space-1); + list-style: none; + margin: 0; + padding: 0; } .avatarLink { diff --git a/packages/ui/src/components/Header/HeaderMetadataUsers.tsx b/packages/ui/src/components/Header/HeaderMetadataUsers.tsx index e644811899..ce1688643f 100644 --- a/packages/ui/src/components/Header/HeaderMetadataUsers.tsx +++ b/packages/ui/src/components/Header/HeaderMetadataUsers.tsx @@ -39,43 +39,10 @@ export const HeaderMetadataUsers = ({ if (users.length === 1) { const user = users[0]; - const avatar = ( - - ); return (
{user.href ? ( - - {avatar} - - ) : ( - avatar - )} - {user.href ? ( - - {user.name} - - ) : ( - {user.name} - )} -
- ); - } - - return ( -
- {users.map(user => ( - - {user.href ? ( + <> - ) : ( - - - - )} - {user.name} - + + {user.name} + + + ) : ( + <> + + {user.name} + + )} +
+ ); + } + + return ( +
    + {users.map(user => ( +
  • + + {user.href ? ( + + + + ) : ( + + + + )} + {user.name} + +
  • ))} -
+ ); }; diff --git a/packages/ui/src/components/Header/definition.ts b/packages/ui/src/components/Header/definition.ts index 7c9878d7e9..c0420dade5 100644 --- a/packages/ui/src/components/Header/definition.ts +++ b/packages/ui/src/components/Header/definition.ts @@ -31,7 +31,7 @@ export const HeaderDefinition = defineComponent()({ tabsWrapper: 'bui-HeaderTabsWrapper', controls: 'bui-HeaderControls', tags: 'bui-HeaderTags', - tagDivider: 'bui-HeaderTagDivider', + tag: 'bui-HeaderTag', description: 'bui-HeaderDescription', metaRow: 'bui-HeaderMetaRow', metaItem: 'bui-HeaderMetaItem', From 3b8c0557c6aef126e4604fb73e480f2f29f0c4e8 Mon Sep 17 00:00:00 2001 From: Aramis Sennyey <159921952+aramissennyeydd@users.noreply.github.com> Date: Wed, 22 Apr 2026 10:12:51 -0400 Subject: [PATCH 081/434] golden-path: backend plugin persistence guide (#33540) * docs: backend plugin persistence guide Signed-off-by: aramissennyeydd * fix prettier Signed-off-by: aramissennyeydd * add dto section Signed-off-by: aramissennyeydd * Apply suggestion from @aramissennyeydd Signed-off-by: Aramis Sennyey <159921952+aramissennyeydd@users.noreply.github.com> * address feedback Signed-off-by: aramissennyeydd * test against real scaffolding Signed-off-by: aramissennyeydd * Apply suggestions from code review Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Signed-off-by: Aramis Sennyey <159921952+aramissennyeydd@users.noreply.github.com> * fix knex migrate:make command to specify migrations directory Without --migrations-directory, knex cannot resolve the config and errors with "Failed to resolve config file". Co-Authored-By: Claude Opus 4.6 (1M context) Signed-off-by: aramissennyeydd * address copilot review feedback - Fix file paths in code snippets to match scaffolded layout (src/ prefix) - Add missing semicolons in toDatabaseRow/fromDatabaseRow return objects - Change knex from devDependency to regular dependency for type imports - Add missing customize-your-instance to adoption sidebar Co-Authored-By: Claude Opus 4.6 (1M context) Signed-off-by: aramissennyeydd --------- Signed-off-by: aramissennyeydd Signed-off-by: Aramis Sennyey <159921952+aramissennyeydd@users.noreply.github.com> Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: Claude Opus 4.6 (1M context) --- .../plugins/backend/003-persistence.md | 317 +++++++++++++++++- ...-tracked.md => 004-reading-from-source.md} | 2 +- microsite/sidebars.ts | 27 +- 3 files changed, 342 insertions(+), 4 deletions(-) rename docs/golden-path/plugins/backend/{004-source-tracked.md => 004-reading-from-source.md} (93%) diff --git a/docs/golden-path/plugins/backend/003-persistence.md b/docs/golden-path/plugins/backend/003-persistence.md index 60d275d7ec..a164d89995 100644 --- a/docs/golden-path/plugins/backend/003-persistence.md +++ b/docs/golden-path/plugins/backend/003-persistence.md @@ -13,8 +13,323 @@ You may have noticed that your list of TODOs disappears after you restart your B SQLite is the default database for local development. It runs in memory (and can also run from a file on disk). It supports quick iteration cycles and can be easily deleted if anything goes wrong. +### What does our data look like at rest? + +Writing to a database requires a table, which requires us to chat quickly about what we want to store. Our TODO object with `title`, `id`, `createdBy` and `createdAt` keys is a good fit to map 1:1 with our database schema. + ## Adding the `databaseService` to your plugin - +### The plumbing + +To start, let's just plumb through the general `databaseService` usage we expect. + +First, add a new service dependency on `databaseService`, + +```diff file="src/services/TodoListService.ts" + +export const todoListServiceRef = createServiceRef>({ + id: 'todo.list', + defaultFactory: async service => + createServiceFactory({ + service, + deps: { + logger: coreServices.logger, + catalog: catalogServiceRef, ++ database: coreServices.database, + }, + async factory(deps) { + return TodoListService.create(deps); + }, + }), +}); +``` + +We then need to add it to our service, + +```diff file="src/services/TodoListService.ts" ++import type { Knex } from 'knex'; +import { + coreServices, + createServiceFactory, + createServiceRef, + LoggerService, ++ DatabaseService, +} from '@backstage/backend-plugin-api'; + +export class TodoListService { ++ readonly #database: Knex; + +- readonly #storedTodos = new Array(); + +- static create(options: { ++ static async create(options: { + logger: LoggerService; + catalog: typeof catalogServiceRef.T; ++ database: DatabaseService; + }) { + const knex = await options.database.getClient(); +- return new TodoListService(options.logger, options.catalog); ++ return new TodoListService(options.logger, options.catalog, knex); + } + + private constructor( + logger: LoggerService, + catalog: typeof catalogServiceRef.T, ++ database: Knex, + ) { + this.#logger = logger; + this.#catalog = catalog; ++ this.#database = database; + } +``` + +And with that, we have an isolated `knex` client to communicate with our database! + +### Creating your table + +Unfortunately, without tables in our database, our `knex` client is not doing much. We need to create a _migration_. Knex stores migrations as JavaScript/TypeScript files that get executed as part of a call to `knex.migrate.latest()`. By default, these are stored in a `migrations/` directory. + +Let's get started. First, we need to install `knex` as a dependency so both its CLI and imported `Knex` types are available, + +```bash +yarn workspace @internal/plugin-todo-backend add knex +``` + +Now, running this command will scaffold a file in that `migrations/` directory for us. + +```bash +yarn workspace @internal/plugin-todo-backend knex migrate:make init --migrations-directory ./migrations +``` + +This should spit out a message like + +```bash +Created Migration: ~/Projects/backstage/backstage/plugins/todo-backend/migrations/20260323130057_init.js +``` + +Let's open that file, + +```js +/** + * @param { import("knex").Knex } knex + * @returns { Promise } + */ +exports.up = async function up(knex) { + // await knex.schema... +}; + +/** + * @param { import("knex").Knex } knex + * @returns { Promise } + */ +exports.down = async function down(knex) { + // await knex.schema... +}; +``` + +You can see two functions, `up` and `down`. `up` is called to apply a migration and `down` is used to undo a previous migration. These should be reversible - if you call `up` and then `down` the database should generally be in the same state if those commands hadn't been run. + +Let's create our table, + +```diff +exports.up = async function up(knex) { ++ await knex.schema.createTable('todo', table => { ++ table.uuid('id').primary(); ++ table.string('created_by', 255).notNullable(); ++ table.string('title').notNullable(); ++ table.datetime('created_at').defaultTo(knex.fn.now()).notNullable(); + ++ table.index(['created_by'], 'todo_user_idx'); + }); +}; +``` + +You'll notice that we use `snake_case` instead of `camelCase` - that's how SQL is conventionally written. + +Let's make sure that we don't forget to add a `down` migration as well! + +```diff +/** + * @param {import('knex').Knex} knex + */ +exports.down = async function down(knex) { ++ await knex.schema.dropTable('todo'); +}; +``` + +Now, we need to actually tell our `knex` client to automatically apply these migrations. We'll add the `database` service to our plugin's `init` function, + +```diff file="src/plugin.ts" +import { + coreServices, + createBackendPlugin, ++ resolvePackagePath, +} from '@backstage/backend-plugin-api'; + +// ... + + deps: { + httpAuth: coreServices.httpAuth, + httpRouter: coreServices.httpRouter, ++ logger: coreServices.logger, ++ database: coreServices.database, + todoList: todoListServiceRef, + }, +- async init({ httpAuth, httpRouter, todoList }) { ++ async init({ httpAuth, logger, httpRouter, database, todoList }) { ++ const knex = await database.getClient(); ++ ++ if (!database.migrations?.skip) { ++ logger.info('Running database migrations...'); ++ ++ const migrationsDir = resolvePackagePath( ++ '@internal/plugin-todo-backend', ++ 'migrations', ++ ); ++ ++ await knex.migrate.latest({ ++ directory: migrationsDir, ++ }); ++ } + + httpRouter.use( + await createRouter({ + httpAuth, + todoList, + }), + ); +``` + +Walking through what we've written - + +1. `database.migrations?.skip` - convention for migrations to allow them to be skipped through config. +1. `const migrationsDir = resolvePackagePath` - ensure the correct migrations directory is passed regardless of environment. +1. `await knex.migrate.latest(` - actually run the migration, calls our `up` method we wrote above. + +We also need to do 1 more thing, + +```diff file="package.json" +"files": [ +- "dist" ++ "dist", ++ "migrations" + ], +``` + +This will make sure the migrations in our plugin work for all users. + +For those who want more details, the full [Knex migration docs](https://knexjs.org/guide/migrations.html#migration-cli) are very informative! + +### Defining our types + +Now that we have our table, we need to add types for it to protect against runtime incompatibilities. For now, these are hand written. + +```diff title="src/services/TodoListService.ts" ++export interface TodoDatabaseRow { ++ title: string; ++ id: string; ++ created_by: string; ++ created_at: string; ++} + +export interface TodoItem { + title: string; + id: string; + createdBy: string; + createdAt: string; +} +``` + +Notice the change to snake case as it has to match the database schema we have above. Now we need to transform `TodoItem` to `TodoDatabaseRow` for writes and `TodoDatabaseRow` to `TodoItem` for reads. + +```diff title="src/services/TodoListService.ts" + private constructor( + logger: LoggerService, + catalog: typeof catalogServiceRef.T, ++ database: Knex, + ) { + this.#logger = logger; + this.#catalog = catalog; ++ this.#database = database; + } + ++ private toDatabaseRow(todo: TodoItem): TodoDatabaseRow { ++ return { ++ id: todo.id, ++ title: todo.title, ++ created_by: todo.createdBy, ++ created_at: todo.createdAt, ++ }; ++ } + ++ private fromDatabaseRow(row: TodoDatabaseRow): TodoItem { ++ return { ++ id: row.id, ++ title: row.title, ++ createdBy: row.created_by, ++ createdAt: row.created_at, ++ }; ++ } +``` + +And that's it! You're now set up to actually read from and write to your database. + +### Writing to your table + +Creating your table was a solid chunk of work - thankfully, writing to it is going to be much easier! + +```diff title="src/services/TodoListService.ts" + + async createTodo( + // ... + const id = crypto.randomUUID(); + const createdBy = options.credentials.principal.userEntityRef; + const newTodo = { + title, + id, + createdBy, + createdAt: new Date().toISOString(), + }; + +- this.#storedTodos.push(newTodo); ++ await this.#database ++ .insert(this.toDatabaseRow(newTodo)) ++ .into('todo'); + + return newTodo; + } +``` + +We've basically just updated our service call to use `this.#database` instead of `this.#storedTodos`. + +### Reading from your table + +Now that we have things in our database, how do we actually get them back out again? + +```diff title="src/services/TodoListService.ts" + + async listTodos(): Promise<{ items: TodoItem[] }> { +- return { items: Array.from(this.#storedTodos) }; ++ const rows = await this.#database('todo').select(); ++ return { items: rows.map(row => this.fromDatabaseRow(row)) }; + } + + async getTodo(request: { id: string }): Promise { +- const todo = this.#storedTodos.find(item => item.id === request.id); ++ const item = await this.#database('todo').where({ id: request.id }).first(); +- if (!todo) { ++ if (!item) { + throw new NotFoundError(`No todo found with id '${request.id}'`); + } +- return todo; ++ return this.fromDatabaseRow(item); + } +``` + +And we're done! ## Testing your changes + +To validate this flow, let's use the same commands that we ran in [the last section of this guide](./002-poking-around.md#testing-locally). + +If everything is working correctly, you will see the same response that you did last time. diff --git a/docs/golden-path/plugins/backend/004-source-tracked.md b/docs/golden-path/plugins/backend/004-reading-from-source.md similarity index 93% rename from docs/golden-path/plugins/backend/004-source-tracked.md rename to docs/golden-path/plugins/backend/004-reading-from-source.md index 54013ccead..87ca4b210e 100644 --- a/docs/golden-path/plugins/backend/004-source-tracked.md +++ b/docs/golden-path/plugins/backend/004-reading-from-source.md @@ -1,5 +1,5 @@ --- -id: source-tracked +id: reading-from-source sidebar_label: 004 - Integrating with SCMs title: 004 - Git-tracked TODOs description: How to ingest TODOs from source code repositories into your plugin diff --git a/microsite/sidebars.ts b/microsite/sidebars.ts index df39e911ff..565c09feb4 100644 --- a/microsite/sidebars.ts +++ b/microsite/sidebars.ts @@ -108,9 +108,32 @@ export default { 'golden-path/plugins/why-build-plugins', 'golden-path/plugins/sustainable-plugin-development', sidebarElementWithIndex({ label: 'Backend Plugins' }, [ - 'golden-path/plugins/backend/001-first-steps', - 'golden-path/plugins/backend/002-poking-around', + 'golden-path/plugins/backend/first-steps', + 'golden-path/plugins/backend/poking-around', + 'golden-path/plugins/backend/persistence', + 'golden-path/plugins/backend/reading-from-source', + 'golden-path/plugins/backend/testing', ]), + sidebarElementWithIndex({ label: 'Frontend Plugins' }, [ + 'golden-path/plugins/frontend/first-steps', + 'golden-path/plugins/frontend/poking-around', + 'golden-path/plugins/frontend/dynamic-config', + 'golden-path/plugins/frontend/http-client', + 'golden-path/plugins/frontend/testing', + ]), + ]), + sidebarElementWithIndex({ label: '003 - Deployment' }, [ + 'golden-path/deployment/index', + ]), + sidebarElementWithIndex({ label: '004 - Adoption' }, [ + 'golden-path/adoption/getting-started', + 'golden-path/adoption/leadership-buy-in', + 'golden-path/adoption/setting-up-a-poc', + 'golden-path/adoption/first-stakeholder-feedback', + 'golden-path/adoption/customize-your-instance', + 'golden-path/adoption/preparing-for-ga', + 'golden-path/adoption/plugin-ownership', + 'golden-path/adoption/full-catalog', ]), ]), ] From 6407493de8e1a067a41178501505e684588f56eb Mon Sep 17 00:00:00 2001 From: Charles de Dreuille Date: Wed, 22 Apr 2026 15:25:41 +0100 Subject: [PATCH 082/434] Update report.api.md Signed-off-by: Charles de Dreuille --- packages/ui/report.api.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/ui/report.api.md b/packages/ui/report.api.md index 08ec6887e0..92fb3d1511 100644 --- a/packages/ui/report.api.md +++ b/packages/ui/report.api.md @@ -1545,7 +1545,7 @@ export const HeaderDefinition: { readonly tabsWrapper: 'bui-HeaderTabsWrapper'; readonly controls: 'bui-HeaderControls'; readonly tags: 'bui-HeaderTags'; - readonly tagDivider: 'bui-HeaderTagDivider'; + readonly tag: 'bui-HeaderTag'; readonly description: 'bui-HeaderDescription'; readonly metaRow: 'bui-HeaderMetaRow'; readonly metaItem: 'bui-HeaderMetaItem'; @@ -1716,7 +1716,7 @@ export const HeaderPageDefinition: { readonly tabsWrapper: 'bui-HeaderTabsWrapper'; readonly controls: 'bui-HeaderControls'; readonly tags: 'bui-HeaderTags'; - readonly tagDivider: 'bui-HeaderTagDivider'; + readonly tag: 'bui-HeaderTag'; readonly description: 'bui-HeaderDescription'; readonly metaRow: 'bui-HeaderMetaRow'; readonly metaItem: 'bui-HeaderMetaItem'; From 19a4d08bd2121fa4e7632ecbe7a3a5f6d623cb7d Mon Sep 17 00:00:00 2001 From: Jan Michael Ong Date: Wed, 22 Apr 2026 08:44:11 -0700 Subject: [PATCH 083/434] chore: add octokit/plugin-retry Signed-off-by: Jan Michael Ong --- plugins/scaffolder-backend-module-github/package.json | 1 + yarn.lock | 1 + 2 files changed, 2 insertions(+) diff --git a/plugins/scaffolder-backend-module-github/package.json b/plugins/scaffolder-backend-module-github/package.json index 982f5692f4..830f5615ff 100644 --- a/plugins/scaffolder-backend-module-github/package.json +++ b/plugins/scaffolder-backend-module-github/package.json @@ -51,6 +51,7 @@ "@backstage/plugin-scaffolder-node": "workspace:^", "@backstage/types": "workspace:^", "@octokit/core": "^5.0.0", + "@octokit/plugin-retry": "^6.0.0", "@octokit/webhooks": "^10.9.2", "libsodium-wrappers": "^0.8.0", "octokit": "^3.0.0", diff --git a/yarn.lock b/yarn.lock index 8e6e449ac4..8be93f0881 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6731,6 +6731,7 @@ __metadata: "@backstage/plugin-scaffolder-node-test-utils": "workspace:^" "@backstage/types": "workspace:^" "@octokit/core": "npm:^5.0.0" + "@octokit/plugin-retry": "npm:^6.0.0" "@octokit/webhooks": "npm:^10.9.2" "@types/libsodium-wrappers": "npm:^0.8.0" fs-extra: "npm:^11.2.0" From e351c07daa672211ffc10d98c6d7c611717c684b Mon Sep 17 00:00:00 2001 From: Jan Michael Ong Date: Wed, 22 Apr 2026 08:44:52 -0700 Subject: [PATCH 084/434] chore: add helper to get an octokit client * add unit tests Signed-off-by: Jan Michael Ong --- .../src/util.test.ts | 154 ++++++++++++++++++ .../src/util.ts | 81 +++++++++ 2 files changed, 235 insertions(+) create mode 100644 plugins/scaffolder-backend-module-github/src/util.test.ts diff --git a/plugins/scaffolder-backend-module-github/src/util.test.ts b/plugins/scaffolder-backend-module-github/src/util.test.ts new file mode 100644 index 0000000000..9cb3035f21 --- /dev/null +++ b/plugins/scaffolder-backend-module-github/src/util.test.ts @@ -0,0 +1,154 @@ +/* + * Copyright 2025 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Octokit } from 'octokit'; +import { retry } from '@octokit/plugin-retry'; +import { mockServices } from '@backstage/backend-test-utils'; +import { isRetryEnabled, getOctokitClient } from './util'; + +jest.mock('octokit', () => ({ + Octokit: Object.assign(jest.fn(), { + plugin: jest.fn(), + }), +})); + +jest.mock('@octokit/plugin-retry', () => ({ + retry: jest.fn(), +})); + +const mockOctokitInstance = { request: jest.fn() }; +const MockOctokit = Octokit as unknown as jest.MockedClass & { + plugin: jest.Mock; +}; + +describe('isRetryEnabled', () => { + it('returns true when both params are undefined', () => { + expect(isRetryEnabled()).toBe(true); + }); + + it('returns true when retries is positive and retryDelay is undefined', () => { + expect(isRetryEnabled(3)).toBe(true); + }); + + it('returns true when retries is undefined and retryDelay is positive', () => { + expect(isRetryEnabled(undefined, 1000)).toBe(true); + }); + + it('returns true when both retries and retryDelay are positive', () => { + expect(isRetryEnabled(5, 2000)).toBe(true); + }); + + it('returns false when retries is 0', () => { + expect(isRetryEnabled(0)).toBe(false); + }); + + it('returns false when retryDelay is 0', () => { + expect(isRetryEnabled(undefined, 0)).toBe(false); + }); + + it('returns false when retries is 0 even if retryDelay is positive', () => { + expect(isRetryEnabled(0, 1000)).toBe(false); + }); + + it('returns false when retryDelay is 0 even if retries is positive', () => { + expect(isRetryEnabled(3, 0)).toBe(false); + }); +}); + +describe('getOctokitClient', () => { + const logger = mockServices.logger.mock(); + const octokitOptions = { + auth: 'test-token', + baseUrl: 'https://api.github.com', + }; + + beforeEach(() => { + jest.clearAllMocks(); + MockOctokit.mockReturnValue(mockOctokitInstance as any); + MockOctokit.plugin.mockReturnValue(MockOctokit); + }); + + it('returns a plain Octokit client when retries is 0', () => { + const client = getOctokitClient(octokitOptions, logger, 0); + + expect(MockOctokit.plugin).not.toHaveBeenCalled(); + expect(MockOctokit).toHaveBeenCalledWith({ + ...octokitOptions, + log: logger, + }); + expect(client).toBe(mockOctokitInstance); + }); + + it('returns a plain Octokit client when retryDelay is 0', () => { + const client = getOctokitClient(octokitOptions, logger, 3, 0); + + expect(MockOctokit.plugin).not.toHaveBeenCalled(); + expect(MockOctokit).toHaveBeenCalledWith({ + ...octokitOptions, + log: logger, + }); + expect(client).toBe(mockOctokitInstance); + }); + + it('returns a retry-enabled Octokit client with default retries and delay', () => { + const client = getOctokitClient(octokitOptions, logger); + + expect(MockOctokit.plugin).toHaveBeenCalledWith(retry); + expect(MockOctokit).toHaveBeenCalledWith({ + ...octokitOptions, + request: { + retries: 3, + retryAfter: 1000, + }, + log: logger, + }); + expect(client).toBe(mockOctokitInstance); + }); + + it('returns a retry-enabled Octokit client with custom retries and delay', () => { + const client = getOctokitClient(octokitOptions, logger, 5, 2000); + + expect(MockOctokit.plugin).toHaveBeenCalledWith(retry); + expect(MockOctokit).toHaveBeenCalledWith({ + ...octokitOptions, + request: { + retries: 5, + retryAfter: 2000, + }, + log: logger, + }); + expect(client).toBe(mockOctokitInstance); + }); + + it('merges existing request options when building retry client', () => { + const optionsWithRequest = { + ...octokitOptions, + request: { timeout: 60_000 }, + }; + + getOctokitClient(optionsWithRequest, logger, 3, 1000); + + expect(MockOctokit).toHaveBeenCalledWith({ + ...optionsWithRequest, + request: { + timeout: 60_000, + retries: 3, + retryAfter: 1000, + }, + log: logger, + }); + }); +}); diff --git a/plugins/scaffolder-backend-module-github/src/util.ts b/plugins/scaffolder-backend-module-github/src/util.ts index 260c93993f..0d50b7c797 100644 --- a/plugins/scaffolder-backend-module-github/src/util.ts +++ b/plugins/scaffolder-backend-module-github/src/util.ts @@ -22,9 +22,90 @@ import { } from '@backstage/integration'; import { parseRepoUrl } from '@backstage/plugin-scaffolder-node'; import { OctokitOptions } from '@octokit/core/dist-types/types'; +import { Octokit } from 'octokit'; +import { retry } from '@octokit/plugin-retry'; +import { LoggerService } from '@backstage/backend-plugin-api'; const DEFAULT_TIMEOUT_MS = 60_000; +// By default, octokit/plugin-retry will retry 3 times with a 1 second delay +// between retries. +const DEFAULT_RETRY_ATTEMPTS = 3; +const DEFAULT_RETRY_DELAY_MS = 1000; + +/** + * Helper function to determine if retries are enabled based on the provided + * options. + * + * Retries are enabled by default, but can be disabled by setting either `retries` + * or `retryDelay` to 0. + * + * @param retries - The number of retry attempts for failed requests. Default is 3. + * Setting to 0 will disable retries. + * @param retryDelay - The delay in milliseconds between retry attempts. + * Default is 1000ms. Setting to 0 will disable retries. + * + * @returns A boolean indicating whether retries are enabled or not. + */ +export function isRetryEnabled(retries?: number, retryDelay?: number): boolean { + if (retries === 0) { + return false; + } + + if (retryDelay === 0) { + return false; + } + + return true; +} + +/** + * Helper for generating an authenticated Octokit client with (or without) + * retry capabilities. + * + * If retries are enabled (default), the client will retry failed requests up + * to the specified number of retries and delay. + * To disable retries, set either `retries` or `retryDelay` to 0 in the options. + * + * @param octokitOptions - The options for configuring the Octokit client. + * Generally provided by the `getOctokitOptions` helper. + * @param logger - LoggerService instance for logging retry attempts and + * failures. + * @param retries - The number of retry attempts for failed requests. + * Default is 3. Setting to 0 will disable retries. + * @param retryDelay - The delay in milliseconds between retry attempts. + * Default is 1000ms. Setting to 0 will disable retries. + * @returns An authenticated Octokit client instance based on the provided + * options. + */ +export function getOctokitClient( + octokitOptions: OctokitOptions, + logger: LoggerService, + retries: number = DEFAULT_RETRY_ATTEMPTS, + retryDelay: number = DEFAULT_RETRY_DELAY_MS, +): Octokit { + // Default behavior is to enable retries, but allow callers to disable by + // explicitly setting retries or retryDelay to 0 + if (!isRetryEnabled(retries, retryDelay)) { + return new Octokit({ + ...octokitOptions, + log: logger, + }); + } + + // Update the octokit options to include retry configuration with logging + const MyOctokit = Octokit.plugin(retry); + return new MyOctokit({ + ...octokitOptions, + request: { + ...octokitOptions.request, + retries, + retryAfter: retryDelay, + }, + log: logger, + }); +} + /** * Helper for generating octokit configuration options. * If no token is provided, it will attempt to get a token from the credentials provider. From a2ee9609001214d72054f5d8393d0301e6868fcf Mon Sep 17 00:00:00 2001 From: Jan Michael Ong Date: Wed, 22 Apr 2026 08:53:29 -0700 Subject: [PATCH 085/434] chore: add changeset Signed-off-by: Jan Michael Ong --- .changeset/fine-cameras-deny.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/fine-cameras-deny.md diff --git a/.changeset/fine-cameras-deny.md b/.changeset/fine-cameras-deny.md new file mode 100644 index 0000000000..9ca36ba0b0 --- /dev/null +++ b/.changeset/fine-cameras-deny.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-scaffolder-backend-module-github': patch +--- + +add helper to return an Octokit client that supports retries (via @octokit/plugin-retry) From d5e2dee26a522948c9d49cc05ccc38ca776606e4 Mon Sep 17 00:00:00 2001 From: Jan Michael Ong Date: Wed, 22 Apr 2026 09:18:54 -0700 Subject: [PATCH 086/434] chore: integrate copilot feedback Signed-off-by: Jan Michael Ong --- .changeset/fine-cameras-deny.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/fine-cameras-deny.md b/.changeset/fine-cameras-deny.md index 9ca36ba0b0..e078b3605e 100644 --- a/.changeset/fine-cameras-deny.md +++ b/.changeset/fine-cameras-deny.md @@ -2,4 +2,4 @@ '@backstage/plugin-scaffolder-backend-module-github': patch --- -add helper to return an Octokit client that supports retries (via @octokit/plugin-retry) +improve Octokit client creation to support retries via @octokit/plugin-retry From 3b460c2e22c02fc5043c0b482f040832019f4c96 Mon Sep 17 00:00:00 2001 From: Jan Michael Ong Date: Wed, 22 Apr 2026 09:19:53 -0700 Subject: [PATCH 087/434] fix: add missing export Signed-off-by: Jan Michael Ong --- plugins/scaffolder-backend-module-github/src/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/scaffolder-backend-module-github/src/index.ts b/plugins/scaffolder-backend-module-github/src/index.ts index cf0bc003cb..8b4276eb78 100644 --- a/plugins/scaffolder-backend-module-github/src/index.ts +++ b/plugins/scaffolder-backend-module-github/src/index.ts @@ -22,4 +22,4 @@ export * from './actions'; export { githubModule as default } from './module'; -export { getOctokitOptions } from './util'; +export { getOctokitClient, getOctokitOptions } from './util'; From 9032ec7f1e0cd2306ce80f84fbe7966890ce9dfb Mon Sep 17 00:00:00 2001 From: Jan Michael Ong Date: Wed, 22 Apr 2026 09:53:01 -0700 Subject: [PATCH 088/434] chore: integrate copilot / awanlin feedback Signed-off-by: Jan Michael Ong --- .../src/util.test.ts | 16 ++++++------ .../src/util.ts | 25 ++++++++++--------- 2 files changed, 21 insertions(+), 20 deletions(-) diff --git a/plugins/scaffolder-backend-module-github/src/util.test.ts b/plugins/scaffolder-backend-module-github/src/util.test.ts index 9cb3035f21..9bbaceee8a 100644 --- a/plugins/scaffolder-backend-module-github/src/util.test.ts +++ b/plugins/scaffolder-backend-module-github/src/util.test.ts @@ -1,5 +1,5 @@ /* - * Copyright 2025 The Backstage Authors + * Copyright 2026 The Backstage Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -39,15 +39,15 @@ describe('isRetryEnabled', () => { expect(isRetryEnabled()).toBe(true); }); - it('returns true when retries is positive and retryDelay is undefined', () => { + it('returns true when retries is positive and retryAfter is undefined', () => { expect(isRetryEnabled(3)).toBe(true); }); - it('returns true when retries is undefined and retryDelay is positive', () => { + it('returns true when retries is undefined and retryAfter is positive', () => { expect(isRetryEnabled(undefined, 1000)).toBe(true); }); - it('returns true when both retries and retryDelay are positive', () => { + it('returns true when both retries and retryAfter are positive', () => { expect(isRetryEnabled(5, 2000)).toBe(true); }); @@ -55,15 +55,15 @@ describe('isRetryEnabled', () => { expect(isRetryEnabled(0)).toBe(false); }); - it('returns false when retryDelay is 0', () => { + it('returns false when retryAfter is 0', () => { expect(isRetryEnabled(undefined, 0)).toBe(false); }); - it('returns false when retries is 0 even if retryDelay is positive', () => { + it('returns false when retries is 0 even if retryAfter is positive', () => { expect(isRetryEnabled(0, 1000)).toBe(false); }); - it('returns false when retryDelay is 0 even if retries is positive', () => { + it('returns false when retryAfter is 0 even if retries is positive', () => { expect(isRetryEnabled(3, 0)).toBe(false); }); }); @@ -92,7 +92,7 @@ describe('getOctokitClient', () => { expect(client).toBe(mockOctokitInstance); }); - it('returns a plain Octokit client when retryDelay is 0', () => { + it('returns a plain Octokit client when retryAfter is 0', () => { const client = getOctokitClient(octokitOptions, logger, 3, 0); expect(MockOctokit.plugin).not.toHaveBeenCalled(); diff --git a/plugins/scaffolder-backend-module-github/src/util.ts b/plugins/scaffolder-backend-module-github/src/util.ts index 0d50b7c797..aa0bea521d 100644 --- a/plugins/scaffolder-backend-module-github/src/util.ts +++ b/plugins/scaffolder-backend-module-github/src/util.ts @@ -38,21 +38,22 @@ const DEFAULT_RETRY_DELAY_MS = 1000; * options. * * Retries are enabled by default, but can be disabled by setting either `retries` - * or `retryDelay` to 0. + * or `retryAfter` to 0. + * @public * * @param retries - The number of retry attempts for failed requests. Default is 3. * Setting to 0 will disable retries. - * @param retryDelay - The delay in milliseconds between retry attempts. + * @param retryAfter - The delay in milliseconds between retry attempts. * Default is 1000ms. Setting to 0 will disable retries. * * @returns A boolean indicating whether retries are enabled or not. */ -export function isRetryEnabled(retries?: number, retryDelay?: number): boolean { +export function isRetryEnabled(retries?: number, retryAfter?: number): boolean { if (retries === 0) { return false; } - if (retryDelay === 0) { + if (retryAfter === 0) { return false; } @@ -65,7 +66,7 @@ export function isRetryEnabled(retries?: number, retryDelay?: number): boolean { * * If retries are enabled (default), the client will retry failed requests up * to the specified number of retries and delay. - * To disable retries, set either `retries` or `retryDelay` to 0 in the options. + * To disable retries, set either `retries` or `retryAfter` to 0 in the options. * * @param octokitOptions - The options for configuring the Octokit client. * Generally provided by the `getOctokitOptions` helper. @@ -73,7 +74,7 @@ export function isRetryEnabled(retries?: number, retryDelay?: number): boolean { * failures. * @param retries - The number of retry attempts for failed requests. * Default is 3. Setting to 0 will disable retries. - * @param retryDelay - The delay in milliseconds between retry attempts. + * @param retryAfter - The delay in milliseconds between retry attempts. * Default is 1000ms. Setting to 0 will disable retries. * @returns An authenticated Octokit client instance based on the provided * options. @@ -82,11 +83,11 @@ export function getOctokitClient( octokitOptions: OctokitOptions, logger: LoggerService, retries: number = DEFAULT_RETRY_ATTEMPTS, - retryDelay: number = DEFAULT_RETRY_DELAY_MS, + retryAfter: number = DEFAULT_RETRY_DELAY_MS, ): Octokit { // Default behavior is to enable retries, but allow callers to disable by - // explicitly setting retries or retryDelay to 0 - if (!isRetryEnabled(retries, retryDelay)) { + // explicitly setting retries or retryAfter to 0 + if (!isRetryEnabled(retries, retryAfter)) { return new Octokit({ ...octokitOptions, log: logger, @@ -94,13 +95,13 @@ export function getOctokitClient( } // Update the octokit options to include retry configuration with logging - const MyOctokit = Octokit.plugin(retry); - return new MyOctokit({ + const OctokitClient = Octokit.plugin(retry); + return new OctokitClient({ ...octokitOptions, request: { ...octokitOptions.request, retries, - retryAfter: retryDelay, + retryAfter, }, log: logger, }); From ad3e56f4acda46f06ab836f5efe88d84495958b8 Mon Sep 17 00:00:00 2001 From: Jan Michael Ong Date: Wed, 22 Apr 2026 10:02:24 -0700 Subject: [PATCH 089/434] chore: fix incorrect placement Signed-off-by: Jan Michael Ong --- plugins/scaffolder-backend-module-github/src/util.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/scaffolder-backend-module-github/src/util.ts b/plugins/scaffolder-backend-module-github/src/util.ts index aa0bea521d..6b9769e675 100644 --- a/plugins/scaffolder-backend-module-github/src/util.ts +++ b/plugins/scaffolder-backend-module-github/src/util.ts @@ -39,7 +39,6 @@ const DEFAULT_RETRY_DELAY_MS = 1000; * * Retries are enabled by default, but can be disabled by setting either `retries` * or `retryAfter` to 0. - * @public * * @param retries - The number of retry attempts for failed requests. Default is 3. * Setting to 0 will disable retries. @@ -67,6 +66,7 @@ export function isRetryEnabled(retries?: number, retryAfter?: number): boolean { * If retries are enabled (default), the client will retry failed requests up * to the specified number of retries and delay. * To disable retries, set either `retries` or `retryAfter` to 0 in the options. + * @public * * @param octokitOptions - The options for configuring the Octokit client. * Generally provided by the `getOctokitOptions` helper. From 5a048ed0425e95bdecd49b80de79d4ebbedbb8b8 Mon Sep 17 00:00:00 2001 From: Jan Michael Ong Date: Wed, 22 Apr 2026 11:13:51 -0700 Subject: [PATCH 090/434] chore: integrate copilot suggestion Signed-off-by: Jan Michael Ong --- .changeset/fine-cameras-deny.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/fine-cameras-deny.md b/.changeset/fine-cameras-deny.md index e078b3605e..f5493c2fa3 100644 --- a/.changeset/fine-cameras-deny.md +++ b/.changeset/fine-cameras-deny.md @@ -2,4 +2,4 @@ '@backstage/plugin-scaffolder-backend-module-github': patch --- -improve Octokit client creation to support retries via @octokit/plugin-retry +Improved Octokit client creation to support retries via @octokit/plugin-retry From 547b91de035413a7bb1d0a3b5aeb7fa23973e67e Mon Sep 17 00:00:00 2001 From: Jan Michael Ong Date: Wed, 22 Apr 2026 11:14:47 -0700 Subject: [PATCH 091/434] chore: switch retryOptions to an options object Signed-off-by: Jan Michael Ong --- .../src/util.test.ts | 77 ++++++++++--------- .../src/util.ts | 51 +++++++----- 2 files changed, 75 insertions(+), 53 deletions(-) diff --git a/plugins/scaffolder-backend-module-github/src/util.test.ts b/plugins/scaffolder-backend-module-github/src/util.test.ts index 9bbaceee8a..ed215b87d0 100644 --- a/plugins/scaffolder-backend-module-github/src/util.test.ts +++ b/plugins/scaffolder-backend-module-github/src/util.test.ts @@ -35,36 +35,39 @@ const MockOctokit = Octokit as unknown as jest.MockedClass & { }; describe('isRetryEnabled', () => { - it('returns true when both params are undefined', () => { - expect(isRetryEnabled()).toBe(true); - }); - - it('returns true when retries is positive and retryAfter is undefined', () => { - expect(isRetryEnabled(3)).toBe(true); - }); - - it('returns true when retries is undefined and retryAfter is positive', () => { - expect(isRetryEnabled(undefined, 1000)).toBe(true); - }); - - it('returns true when both retries and retryAfter are positive', () => { - expect(isRetryEnabled(5, 2000)).toBe(true); - }); - - it('returns false when retries is 0', () => { - expect(isRetryEnabled(0)).toBe(false); - }); - - it('returns false when retryAfter is 0', () => { - expect(isRetryEnabled(undefined, 0)).toBe(false); - }); - - it('returns false when retries is 0 even if retryAfter is positive', () => { - expect(isRetryEnabled(0, 1000)).toBe(false); - }); - - it('returns false when retryAfter is 0 even if retries is positive', () => { - expect(isRetryEnabled(3, 0)).toBe(false); + it.each([ + { + description: 'returns false when retries is == 0', + options: { retries: 0 }, + expected: false, + }, + { + description: 'returns false when retries is <= 0', + options: { retries: -10 }, + expected: false, + }, + { + description: 'returns false when retryAfter is == 0', + options: { retries: 1, retryAfter: 0 }, + expected: false, + }, + { + description: 'returns false when retryAfter is <= 0', + options: { retries: 1, retryAfter: -10 }, + expected: false, + }, + { + description: 'returns true when retryOptions is undefined', + options: undefined, + expected: true, + }, + { + description: 'returns true when retries is > 0 and retryAfter is > 0', + options: { retries: 5, retryAfter: 5 }, + expected: true, + }, + ])('$description', ({ options, expected }) => { + expect(isRetryEnabled(options)).toBe(expected); }); }); @@ -82,7 +85,8 @@ describe('getOctokitClient', () => { }); it('returns a plain Octokit client when retries is 0', () => { - const client = getOctokitClient(octokitOptions, logger, 0); + const retryOptions = { retries: 0 }; + const client = getOctokitClient(octokitOptions, logger, retryOptions); expect(MockOctokit.plugin).not.toHaveBeenCalled(); expect(MockOctokit).toHaveBeenCalledWith({ @@ -93,7 +97,8 @@ describe('getOctokitClient', () => { }); it('returns a plain Octokit client when retryAfter is 0', () => { - const client = getOctokitClient(octokitOptions, logger, 3, 0); + const retryOptions = { retries: 3, retryAfter: 0 }; + const client = getOctokitClient(octokitOptions, logger, retryOptions); expect(MockOctokit.plugin).not.toHaveBeenCalled(); expect(MockOctokit).toHaveBeenCalledWith({ @@ -103,7 +108,7 @@ describe('getOctokitClient', () => { expect(client).toBe(mockOctokitInstance); }); - it('returns a retry-enabled Octokit client with default retries and delay', () => { + it('returns a retry-enabled Octokit client with default retries and delay when retryOptions is undefined', () => { const client = getOctokitClient(octokitOptions, logger); expect(MockOctokit.plugin).toHaveBeenCalledWith(retry); @@ -119,7 +124,8 @@ describe('getOctokitClient', () => { }); it('returns a retry-enabled Octokit client with custom retries and delay', () => { - const client = getOctokitClient(octokitOptions, logger, 5, 2000); + const retryOptions = { retries: 5, retryAfter: 2000 }; + const client = getOctokitClient(octokitOptions, logger, retryOptions); expect(MockOctokit.plugin).toHaveBeenCalledWith(retry); expect(MockOctokit).toHaveBeenCalledWith({ @@ -139,7 +145,8 @@ describe('getOctokitClient', () => { request: { timeout: 60_000 }, }; - getOctokitClient(optionsWithRequest, logger, 3, 1000); + const retryOptions = { retries: 3, retryAfter: 1000 }; + getOctokitClient(optionsWithRequest, logger, retryOptions); expect(MockOctokit).toHaveBeenCalledWith({ ...optionsWithRequest, diff --git a/plugins/scaffolder-backend-module-github/src/util.ts b/plugins/scaffolder-backend-module-github/src/util.ts index 6b9769e675..e4965bff5b 100644 --- a/plugins/scaffolder-backend-module-github/src/util.ts +++ b/plugins/scaffolder-backend-module-github/src/util.ts @@ -33,26 +33,33 @@ const DEFAULT_TIMEOUT_MS = 60_000; const DEFAULT_RETRY_ATTEMPTS = 3; const DEFAULT_RETRY_DELAY_MS = 1000; +type RetryOptions = { + // The number of retry attempts for failed requests + retries?: number; + // The delay in milliseconds between retry attempts + retryAfter?: number; +}; + /** * Helper function to determine if retries are enabled based on the provided * options. * - * Retries are enabled by default, but can be disabled by setting either `retries` - * or `retryAfter` to 0. + * @param retryOptions - Optional retry configuration options, including the number + * of retries and the delay between retries. * - * @param retries - The number of retry attempts for failed requests. Default is 3. - * Setting to 0 will disable retries. - * @param retryAfter - The delay in milliseconds between retry attempts. - * Default is 1000ms. Setting to 0 will disable retries. - * - * @returns A boolean indicating whether retries are enabled or not. + * @returns False if retries/retryAfter are explicitly set to 0 or less. + * Returns true otherwise. */ -export function isRetryEnabled(retries?: number, retryAfter?: number): boolean { - if (retries === 0) { +export function isRetryEnabled(retryOptions?: RetryOptions): boolean { + if (retryOptions === undefined) { + return true; + } + + if (retryOptions.retries !== undefined && retryOptions.retries <= 0) { return false; } - if (retryAfter === 0) { + if (retryOptions.retryAfter !== undefined && retryOptions.retryAfter <= 0) { return false; } @@ -72,22 +79,20 @@ export function isRetryEnabled(retries?: number, retryAfter?: number): boolean { * Generally provided by the `getOctokitOptions` helper. * @param logger - LoggerService instance for logging retry attempts and * failures. - * @param retries - The number of retry attempts for failed requests. - * Default is 3. Setting to 0 will disable retries. - * @param retryAfter - The delay in milliseconds between retry attempts. - * Default is 1000ms. Setting to 0 will disable retries. + * @param retryOptions - Optional retry configuration options, including the + * number of retries and the delay between retries. + * * @returns An authenticated Octokit client instance based on the provided * options. */ export function getOctokitClient( octokitOptions: OctokitOptions, logger: LoggerService, - retries: number = DEFAULT_RETRY_ATTEMPTS, - retryAfter: number = DEFAULT_RETRY_DELAY_MS, + retryOptions?: RetryOptions, ): Octokit { // Default behavior is to enable retries, but allow callers to disable by // explicitly setting retries or retryAfter to 0 - if (!isRetryEnabled(retries, retryAfter)) { + if (!isRetryEnabled(retryOptions)) { return new Octokit({ ...octokitOptions, log: logger, @@ -96,6 +101,16 @@ export function getOctokitClient( // Update the octokit options to include retry configuration with logging const OctokitClient = Octokit.plugin(retry); + + // From the octokit/plugin-retry documentation, specifying these values will + // always retry regardless of the response code + const retries = retryOptions?.retries + ? retryOptions?.retries + : DEFAULT_RETRY_ATTEMPTS; + const retryAfter = retryOptions?.retryAfter + ? retryOptions?.retryAfter + : DEFAULT_RETRY_DELAY_MS; + return new OctokitClient({ ...octokitOptions, request: { From a50f4ec8189e78181a543a5319c5bb70e3993bbd Mon Sep 17 00:00:00 2001 From: Jan Michael Ong Date: Wed, 22 Apr 2026 11:35:23 -0700 Subject: [PATCH 092/434] chore: incorporate additional copilot suggestions Signed-off-by: Jan Michael Ong --- plugins/scaffolder-backend-module-github/src/index.ts | 2 +- plugins/scaffolder-backend-module-github/src/util.ts | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/plugins/scaffolder-backend-module-github/src/index.ts b/plugins/scaffolder-backend-module-github/src/index.ts index 8b4276eb78..27e6ae60a5 100644 --- a/plugins/scaffolder-backend-module-github/src/index.ts +++ b/plugins/scaffolder-backend-module-github/src/index.ts @@ -22,4 +22,4 @@ export * from './actions'; export { githubModule as default } from './module'; -export { getOctokitClient, getOctokitOptions } from './util'; +export { getOctokitClient, getOctokitOptions, type RetryOptions } from './util'; diff --git a/plugins/scaffolder-backend-module-github/src/util.ts b/plugins/scaffolder-backend-module-github/src/util.ts index e4965bff5b..f9cd695b83 100644 --- a/plugins/scaffolder-backend-module-github/src/util.ts +++ b/plugins/scaffolder-backend-module-github/src/util.ts @@ -31,9 +31,9 @@ const DEFAULT_TIMEOUT_MS = 60_000; // By default, octokit/plugin-retry will retry 3 times with a 1 second delay // between retries. const DEFAULT_RETRY_ATTEMPTS = 3; -const DEFAULT_RETRY_DELAY_MS = 1000; +const DEFAULT_RETRY_DELAY_MS = 1_000; -type RetryOptions = { +export type RetryOptions = { // The number of retry attempts for failed requests retries?: number; // The delay in milliseconds between retry attempts From e72787948cf6fdc4b6aef5d629678d01d70a3431 Mon Sep 17 00:00:00 2001 From: Jan Michael Ong Date: Wed, 22 Apr 2026 12:01:06 -0700 Subject: [PATCH 093/434] chore: fix Warning: (ae-forgotten-export) Signed-off-by: Jan Michael Ong --- .../report.api.md | 18 ++++++++++++++++-- .../src/util.ts | 4 ++++ 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/plugins/scaffolder-backend-module-github/report.api.md b/plugins/scaffolder-backend-module-github/report.api.md index 9e930a7866..84e71c712a 100644 --- a/plugins/scaffolder-backend-module-github/report.api.md +++ b/plugins/scaffolder-backend-module-github/report.api.md @@ -8,6 +8,7 @@ import { CatalogService } from '@backstage/plugin-catalog-node'; import { Config } from '@backstage/config'; import { createPullRequest } from 'octokit-plugin-create-pull-request'; import { GithubCredentialsProvider } from '@backstage/integration'; +import { LoggerService } from '@backstage/backend-plugin-api'; import { Octokit } from 'octokit'; import { OctokitOptions } from '@octokit/core/dist-types/types'; import { ScmIntegrationRegistry } from '@backstage/integration'; @@ -253,8 +254,8 @@ export function createGithubRepoCreateAction(options: { access: string; } | { - team: string; access: string; + team: string; } )[] | undefined; @@ -444,8 +445,8 @@ export function createPublishGithubAction(options: { access: string; } | { - team: string; access: string; + team: string; } )[] | undefined; @@ -509,6 +510,13 @@ export const createPublishGithubPullRequestAction: ( 'v2' >; +// @public +export function getOctokitClient( + octokitOptions: OctokitOptions, + logger: LoggerService, + retryOptions?: RetryOptions, +): Octokit; + // @public export function getOctokitOptions(options: { integrations: ScmIntegrationRegistry; @@ -530,4 +538,10 @@ export function getOctokitOptions(options: { // @public const githubModule: BackendFeature; export default githubModule; + +// @public +export type RetryOptions = { + retries?: number; + retryAfter?: number; +}; ``` diff --git a/plugins/scaffolder-backend-module-github/src/util.ts b/plugins/scaffolder-backend-module-github/src/util.ts index f9cd695b83..5e537582ca 100644 --- a/plugins/scaffolder-backend-module-github/src/util.ts +++ b/plugins/scaffolder-backend-module-github/src/util.ts @@ -33,6 +33,10 @@ const DEFAULT_TIMEOUT_MS = 60_000; const DEFAULT_RETRY_ATTEMPTS = 3; const DEFAULT_RETRY_DELAY_MS = 1_000; +/** + * Options used to override the default retry behavior of @octokit/plugin-retry. + * @public + */ export type RetryOptions = { // The number of retry attempts for failed requests retries?: number; From f38885379116558f73ae600ad12b8b28a0f1eebd Mon Sep 17 00:00:00 2001 From: Jan Michael Ong Date: Wed, 22 Apr 2026 12:14:41 -0700 Subject: [PATCH 094/434] chore: fix tsdoc-characters-after-block-tag warning Signed-off-by: Jan Michael Ong --- plugins/scaffolder-backend-module-github/src/util.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/scaffolder-backend-module-github/src/util.ts b/plugins/scaffolder-backend-module-github/src/util.ts index 5e537582ca..09a8b0b6dd 100644 --- a/plugins/scaffolder-backend-module-github/src/util.ts +++ b/plugins/scaffolder-backend-module-github/src/util.ts @@ -34,7 +34,7 @@ const DEFAULT_RETRY_ATTEMPTS = 3; const DEFAULT_RETRY_DELAY_MS = 1_000; /** - * Options used to override the default retry behavior of @octokit/plugin-retry. + * Options used to override plugin-retry defaults * @public */ export type RetryOptions = { From 8d60c70e5d1057467d0ff3becb07a1c3c42f5d86 Mon Sep 17 00:00:00 2001 From: Jan Michael Ong Date: Wed, 22 Apr 2026 12:38:37 -0700 Subject: [PATCH 095/434] chore: fix uncommitted changes to the public API or reports of a package error Signed-off-by: Jan Michael Ong --- plugins/scaffolder-backend-module-github/report.api.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/plugins/scaffolder-backend-module-github/report.api.md b/plugins/scaffolder-backend-module-github/report.api.md index 84e71c712a..76faa3582c 100644 --- a/plugins/scaffolder-backend-module-github/report.api.md +++ b/plugins/scaffolder-backend-module-github/report.api.md @@ -254,8 +254,8 @@ export function createGithubRepoCreateAction(options: { access: string; } | { - access: string; team: string; + access: string; } )[] | undefined; @@ -445,8 +445,8 @@ export function createPublishGithubAction(options: { access: string; } | { - access: string; team: string; + access: string; } )[] | undefined; From b6ca66681283e1d395d6a1147eb19db55f384a7c Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Thu, 23 Apr 2026 00:38:05 +0200 Subject: [PATCH 096/434] frontend-app-api: isolate invalid feature flag registrations Wrap each feature flag registration in a try/catch so that a single invalid flag name (e.g. containing a slash) is reported through the error collector instead of crashing the entire app at bootstrap. Signed-off-by: Patrik Oldsberg Made-with: Cursor --- .changeset/isolate-invalid-feature-flags.md | 5 + packages/frontend-app-api/report.api.md | 7 + .../src/wiring/apiFactories.test.ts | 295 ++++++++++++++++++ .../src/wiring/apiFactories.ts | 60 +++- .../src/wiring/createErrorCollector.ts | 3 + .../src/wiring/prepareSpecializedApp.tsx | 4 +- 6 files changed, 355 insertions(+), 19 deletions(-) create mode 100644 .changeset/isolate-invalid-feature-flags.md create mode 100644 packages/frontend-app-api/src/wiring/apiFactories.test.ts diff --git a/.changeset/isolate-invalid-feature-flags.md b/.changeset/isolate-invalid-feature-flags.md new file mode 100644 index 0000000000..eb20fb85ca --- /dev/null +++ b/.changeset/isolate-invalid-feature-flags.md @@ -0,0 +1,5 @@ +--- +'@backstage/frontend-app-api': patch +--- + +Invalid feature flag declarations no longer crash the app during bootstrap. They are now reported through the error collector and skipped, letting the rest of the app load normally. diff --git a/packages/frontend-app-api/report.api.md b/packages/frontend-app-api/report.api.md index b6b502e2da..d3026bf538 100644 --- a/packages/frontend-app-api/report.api.md +++ b/packages/frontend-app-api/report.api.md @@ -148,6 +148,13 @@ export type AppErrorTypes = { bootstrapPluginId: string; }; }; + FEATURE_FLAG_INVALID: { + context: { + pluginId: string; + flagName: string; + error: Error; + }; + }; ROUTE_DUPLICATE: { context: { routeId: string; diff --git a/packages/frontend-app-api/src/wiring/apiFactories.test.ts b/packages/frontend-app-api/src/wiring/apiFactories.test.ts new file mode 100644 index 0000000000..34fa2fa3ee --- /dev/null +++ b/packages/frontend-app-api/src/wiring/apiFactories.test.ts @@ -0,0 +1,295 @@ +/* + * Copyright 2025 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { + createFrontendPlugin, + createFrontendModule, + featureFlagsApiRef, + createApiRef, + type FeatureFlagsApi, +} from '@backstage/frontend-plugin-api'; +import { + registerFeatureFlagDeclarationsInHolder, + wrapFeatureFlagApiFactory, +} from './apiFactories'; +import { createErrorCollector } from './createErrorCollector'; + +function createValidatingMockFeatureFlagsApi(): FeatureFlagsApi & { + flags: Array<{ name: string; pluginId: string }>; +} { + const flagNameRegex = /^[a-z]+[a-z0-9-]+$/; + const flags = new Array<{ name: string; pluginId: string }>(); + return { + flags, + registerFlag(flag) { + if (!flagNameRegex.test(flag.name)) { + throw new Error( + `The '${flag.name}' feature flag must start with a lowercase letter and only contain lowercase letters, numbers and hyphens.`, + ); + } + flags.push(flag); + }, + getRegisteredFlags() { + return flags; + }, + isActive() { + return false; + }, + save() {}, + }; +} + +describe('registerFeatureFlagDeclarationsInHolder', () => { + it('should isolate invalid flags and still register valid ones from a plugin', () => { + const featureFlagsApi = createValidatingMockFeatureFlagsApi(); + const collector = createErrorCollector(); + + const plugin = createFrontendPlugin({ + pluginId: 'test', + featureFlags: [ + { name: 'valid-flag' }, + { name: 'bad/flag' }, + { name: 'another-valid' }, + ], + extensions: [], + }); + + registerFeatureFlagDeclarationsInHolder( + { + get: ref => + (ref.id === featureFlagsApiRef.id + ? featureFlagsApi + : undefined) as any, + }, + [plugin], + collector, + ); + + expect(featureFlagsApi.flags).toEqual([ + expect.objectContaining({ name: 'valid-flag', pluginId: 'test' }), + expect.objectContaining({ name: 'another-valid', pluginId: 'test' }), + ]); + + const errors = collector.collectErrors()!; + expect(errors).toHaveLength(1); + expect(errors[0]).toMatchObject({ + code: 'FEATURE_FLAG_INVALID', + context: { pluginId: 'test', flagName: 'bad/flag' }, + }); + expect(errors[0].message).toContain("Plugin 'test'"); + expect(errors[0].message).toContain("'bad/flag'"); + }); + + it('should report each invalid flag independently across multiple plugins', () => { + const featureFlagsApi = createValidatingMockFeatureFlagsApi(); + const collector = createErrorCollector(); + + const pluginA = createFrontendPlugin({ + pluginId: 'alpha', + featureFlags: [{ name: 'ok-flag' }, { name: 'UPPER' }], + extensions: [], + }); + const pluginB = createFrontendPlugin({ + pluginId: 'beta', + featureFlags: [{ name: 'x/y' }, { name: 'good-one' }], + extensions: [], + }); + + registerFeatureFlagDeclarationsInHolder( + { + get: ref => + (ref.id === featureFlagsApiRef.id + ? featureFlagsApi + : undefined) as any, + }, + [pluginA, pluginB], + collector, + ); + + expect(featureFlagsApi.flags).toEqual([ + expect.objectContaining({ name: 'ok-flag', pluginId: 'alpha' }), + expect.objectContaining({ name: 'good-one', pluginId: 'beta' }), + ]); + + const errors = collector.collectErrors()!; + expect(errors).toHaveLength(2); + expect(errors).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + code: 'FEATURE_FLAG_INVALID', + context: expect.objectContaining({ + pluginId: 'alpha', + flagName: 'UPPER', + }), + }), + expect.objectContaining({ + code: 'FEATURE_FLAG_INVALID', + context: expect.objectContaining({ + pluginId: 'beta', + flagName: 'x/y', + }), + }), + ]), + ); + }); + + it('should isolate invalid flags declared by a frontend module', () => { + const featureFlagsApi = createValidatingMockFeatureFlagsApi(); + const collector = createErrorCollector(); + + const mod = createFrontendModule({ + pluginId: 'my-plugin', + featureFlags: [{ name: 'mod-valid' }, { name: 'mod/invalid' }], + extensions: [], + }); + + registerFeatureFlagDeclarationsInHolder( + { + get: ref => + (ref.id === featureFlagsApiRef.id + ? featureFlagsApi + : undefined) as any, + }, + [mod], + collector, + ); + + expect(featureFlagsApi.flags).toEqual([ + expect.objectContaining({ name: 'mod-valid', pluginId: 'my-plugin' }), + ]); + + const errors = collector.collectErrors()!; + expect(errors).toHaveLength(1); + expect(errors[0]).toMatchObject({ + code: 'FEATURE_FLAG_INVALID', + context: { pluginId: 'my-plugin', flagName: 'mod/invalid' }, + }); + }); + + it('should isolate non-validation errors thrown by registerFlag', () => { + const featureFlagsApi: FeatureFlagsApi = { + registerFlag() { + throw new Error('database connection lost'); + }, + getRegisteredFlags: () => [], + isActive: () => false, + save() {}, + }; + const collector = createErrorCollector(); + + const plugin = createFrontendPlugin({ + pluginId: 'broken', + featureFlags: [{ name: 'any-flag' }], + extensions: [], + }); + + registerFeatureFlagDeclarationsInHolder( + { + get: ref => + (ref.id === featureFlagsApiRef.id + ? featureFlagsApi + : undefined) as any, + }, + [plugin], + collector, + ); + + const errors = collector.collectErrors()!; + expect(errors).toHaveLength(1); + expect(errors[0]).toMatchObject({ + code: 'FEATURE_FLAG_INVALID', + context: expect.objectContaining({ + pluginId: 'broken', + flagName: 'any-flag', + }), + }); + expect(errors[0].message).toContain('database connection lost'); + }); +}); + +describe('wrapFeatureFlagApiFactory', () => { + it('should not wrap factories for non-feature-flag APIs', () => { + const otherApiRef = createApiRef<{}>({ id: 'test.other' }); + const factory = { + api: otherApiRef, + deps: {}, + factory: () => ({}), + }; + const collector = createErrorCollector(); + + const result = wrapFeatureFlagApiFactory(factory, [], collector); + expect(result).toBe(factory); + }); + + it('should wrap the feature flags factory and isolate invalid flags', () => { + const featureFlagsApi = createValidatingMockFeatureFlagsApi(); + const collector = createErrorCollector(); + + const plugin = createFrontendPlugin({ + pluginId: 'test', + featureFlags: [{ name: 'good-flag' }, { name: 'bad/flag' }], + extensions: [], + }); + + const factory = { + api: featureFlagsApiRef, + deps: {}, + factory: () => featureFlagsApi, + }; + + const wrapped = wrapFeatureFlagApiFactory(factory, [plugin], collector); + expect(wrapped).not.toBe(factory); + + const result = wrapped.factory({}); + expect(result).toBe(featureFlagsApi); + expect(featureFlagsApi.flags).toEqual([ + expect.objectContaining({ name: 'good-flag', pluginId: 'test' }), + ]); + + const errors = collector.collectErrors()!; + expect(errors).toHaveLength(1); + expect(errors[0]).toMatchObject({ + code: 'FEATURE_FLAG_INVALID', + context: { pluginId: 'test', flagName: 'bad/flag' }, + }); + }); + + it('should not throw from the wrapped factory when flags are invalid', () => { + const featureFlagsApi = createValidatingMockFeatureFlagsApi(); + const collector = createErrorCollector(); + + const plugin = createFrontendPlugin({ + pluginId: 'crashy', + featureFlags: [{ name: 'inspt/show-test-banner' }], + extensions: [], + }); + + const factory = { + api: featureFlagsApiRef, + deps: {}, + factory: () => featureFlagsApi, + }; + + const wrapped = wrapFeatureFlagApiFactory(factory, [plugin], collector); + + expect(() => wrapped.factory({})).not.toThrow(); + expect(featureFlagsApi.flags).toEqual([]); + + const errors = collector.collectErrors()!; + expect(errors).toHaveLength(1); + expect(errors[0].code).toBe('FEATURE_FLAG_INVALID'); + }); +}); diff --git a/packages/frontend-app-api/src/wiring/apiFactories.ts b/packages/frontend-app-api/src/wiring/apiFactories.ts index 6ccc75b9c1..3196c5de46 100644 --- a/packages/frontend-app-api/src/wiring/apiFactories.ts +++ b/packages/frontend-app-api/src/wiring/apiFactories.ts @@ -51,10 +51,11 @@ export type ApiFactoryEntry = { export function registerFeatureFlagDeclarationsInHolder( apis: ApiHolder, features: FrontendFeature[], + collector: ErrorCollector, ) { const featureFlagApi = apis.get(featureFlagsApiRef); if (featureFlagApi) { - registerFeatureFlagDeclarations(featureFlagApi, features); + registerFeatureFlagDeclarations(featureFlagApi, features, collector); } } @@ -65,6 +66,7 @@ export function registerFeatureFlagDeclarationsInHolder( export function wrapFeatureFlagApiFactory( factory: AnyApiFactory, features: FrontendFeature[], + collector: ErrorCollector, ) { if (factory.api.id !== featureFlagsApiRef.id) { return factory; @@ -76,7 +78,7 @@ export function wrapFeatureFlagApiFactory( const featureFlagApi = factory.factory( deps, ) as typeof featureFlagsApiRef.T; - registerFeatureFlagDeclarations(featureFlagApi, features); + registerFeatureFlagDeclarations(featureFlagApi, features, collector); return featureFlagApi; }, } as AnyApiFactory; @@ -137,7 +139,11 @@ export function syncFinalApiFactories(options: { return true; }); const changedFactories = changedEntries.map(entry => - wrapFeatureFlagApiFactory(entry.factory, options.features), + wrapFeatureFlagApiFactory( + entry.factory, + options.features, + options.collector, + ), ); options.appApiRegistry.setAll(changedFactories); options.apiResolver.invalidate( @@ -174,25 +180,45 @@ const EMPTY_API_HOLDER: ApiHolder = { function registerFeatureFlagDeclarations( featureFlagApi: typeof featureFlagsApiRef.T, features: FrontendFeature[], + collector: ErrorCollector, ) { for (const feature of features) { if (OpaqueFrontendPlugin.isType(feature)) { - OpaqueFrontendPlugin.toInternal(feature).featureFlags.forEach(flag => - featureFlagApi.registerFlag({ - name: flag.name, - description: flag.description, - pluginId: feature.id, - }), - ); + const pluginId = feature.id; + for (const flag of OpaqueFrontendPlugin.toInternal(feature) + .featureFlags) { + try { + featureFlagApi.registerFlag({ + name: flag.name, + description: flag.description, + pluginId, + }); + } catch (error) { + collector.report({ + code: 'FEATURE_FLAG_INVALID', + message: `Plugin '${pluginId}' declared invalid feature flag '${flag.name}': ${error}`, + context: { pluginId, flagName: flag.name, error: error as Error }, + }); + } + } } if (isInternalFrontendModule(feature)) { - toInternalFrontendModule(feature).featureFlags.forEach(flag => - featureFlagApi.registerFlag({ - name: flag.name, - description: flag.description, - pluginId: feature.pluginId, - }), - ); + const pluginId = feature.pluginId; + for (const flag of toInternalFrontendModule(feature).featureFlags) { + try { + featureFlagApi.registerFlag({ + name: flag.name, + description: flag.description, + pluginId, + }); + } catch (error) { + collector.report({ + code: 'FEATURE_FLAG_INVALID', + message: `Plugin '${pluginId}' declared invalid feature flag '${flag.name}': ${error}`, + context: { pluginId, flagName: flag.name, error: error as Error }, + }); + } + } } } } diff --git a/packages/frontend-app-api/src/wiring/createErrorCollector.ts b/packages/frontend-app-api/src/wiring/createErrorCollector.ts index 7077e0e9c4..5cf839b286 100644 --- a/packages/frontend-app-api/src/wiring/createErrorCollector.ts +++ b/packages/frontend-app-api/src/wiring/createErrorCollector.ts @@ -97,6 +97,9 @@ export type AppErrorTypes = { bootstrapPluginId: string; }; }; + FEATURE_FLAG_INVALID: { + context: { pluginId: string; flagName: string; error: Error }; + }; // routing ROUTE_DUPLICATE: { context: { routeId: string }; diff --git a/packages/frontend-app-api/src/wiring/prepareSpecializedApp.tsx b/packages/frontend-app-api/src/wiring/prepareSpecializedApp.tsx index 3a09804a18..517abe3669 100644 --- a/packages/frontend-app-api/src/wiring/prepareSpecializedApp.tsx +++ b/packages/frontend-app-api/src/wiring/prepareSpecializedApp.tsx @@ -342,7 +342,7 @@ export function prepareSpecializedApp( if (providedApis) { // Reused session state already carries a fully prepared API holder, so the // bootstrap path only needs to register feature flag declarations on top. - registerFeatureFlagDeclarationsInHolder(providedApis, features); + registerFeatureFlagDeclarationsInHolder(providedApis, features, collector); } else { // Bootstrap materializes only the immediately visible API factories. Any // predicate-gated API roots are revisited during finalization. @@ -355,7 +355,7 @@ export function prepareSpecializedApp( }); const apiFactories = Array.from( bootstrapApiFactoryEntries.values(), - entry => wrapFeatureFlagApiFactory(entry.factory, features), + entry => wrapFeatureFlagApiFactory(entry.factory, features, collector), ); appApiRegistry.registerAll(apiFactories); } From 23ee7899b0d2e01a0fc5b3b1118a2b9033553199 Mon Sep 17 00:00:00 2001 From: Deepthi Ajith Date: Wed, 8 Apr 2026 10:26:00 +0000 Subject: [PATCH 097/434] fix(ui): add invalid state styling to Checkbox component Signed-off-by: Deepthi Ajith --- .changeset/mean-monkeys-create.md | 5 +++++ .../ui/src/components/Checkbox/Checkbox.module.css | 13 +++++++++++++ .../ui/src/components/Checkbox/Checkbox.stories.tsx | 13 +++++++++++++ 3 files changed, 31 insertions(+) create mode 100644 .changeset/mean-monkeys-create.md diff --git a/.changeset/mean-monkeys-create.md b/.changeset/mean-monkeys-create.md new file mode 100644 index 0000000000..270d779753 --- /dev/null +++ b/.changeset/mean-monkeys-create.md @@ -0,0 +1,5 @@ +--- +'@backstage/ui': minor +--- + +Added diff --git a/packages/ui/src/components/Checkbox/Checkbox.module.css b/packages/ui/src/components/Checkbox/Checkbox.module.css index 54705d95e9..09fc386ff1 100644 --- a/packages/ui/src/components/Checkbox/Checkbox.module.css +++ b/packages/ui/src/components/Checkbox/Checkbox.module.css @@ -68,6 +68,19 @@ color: var(--bui-fg-primary); } + .bui-Checkbox[data-invalid] & { + box-shadow: inset 0 0 0 1px var(--bui-border-danger); + } + + .bui-Checkbox[data-invalid][data-selected] & { + background-color: var(--bui-border-danger); + box-shadow: none; + } + + .bui-Checkbox[data-invalid][data-indeterminate] & { + box-shadow: inset 0 0 0 1px var(--bui-border-danger); + } + @media (prefers-reduced-motion: reduce) { & { transition: none; diff --git a/packages/ui/src/components/Checkbox/Checkbox.stories.tsx b/packages/ui/src/components/Checkbox/Checkbox.stories.tsx index 94811d5cb6..1ef1e873f4 100644 --- a/packages/ui/src/components/Checkbox/Checkbox.stories.tsx +++ b/packages/ui/src/components/Checkbox/Checkbox.stories.tsx @@ -62,6 +62,12 @@ export const WithLongText = Default.extend({ ], }); +export const Invalid = Default.extend({ + args: { + isInvalid: true, + }, +}); + export const AllVariants = meta.story({ ...Default.input, render: () => ( @@ -76,6 +82,13 @@ export const AllVariants = meta.story({ Indeterminate & Disabled + Invalid + + Invalid & Checked + + + Invalid & Indeterminate + ), }); From c4a45bd2f41ae5e0a24c1ffb6ddad682507bda82 Mon Sep 17 00:00:00 2001 From: Deepthi Ajith Date: Wed, 8 Apr 2026 10:34:09 +0000 Subject: [PATCH 098/434] Changeset added Signed-off-by: Deepthi Ajith --- .changeset/mean-monkeys-create.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.changeset/mean-monkeys-create.md b/.changeset/mean-monkeys-create.md index 270d779753..d32157c4aa 100644 --- a/.changeset/mean-monkeys-create.md +++ b/.changeset/mean-monkeys-create.md @@ -1,5 +1,5 @@ --- -'@backstage/ui': minor +'@backstage/ui': patch --- -Added +Added invalid-state styling for Checkbox and corresponding Storybook variants for verification. From 9632e903eb222c3974d2cde8228b8b4dcdc26064 Mon Sep 17 00:00:00 2001 From: Johan Persson Date: Tue, 21 Apr 2026 09:37:27 +0200 Subject: [PATCH 099/434] Update .changeset/mean-monkeys-create.md Signed-off-by: Johan Persson --- .changeset/mean-monkeys-create.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.changeset/mean-monkeys-create.md b/.changeset/mean-monkeys-create.md index d32157c4aa..d2f2571b46 100644 --- a/.changeset/mean-monkeys-create.md +++ b/.changeset/mean-monkeys-create.md @@ -3,3 +3,5 @@ --- Added invalid-state styling for Checkbox and corresponding Storybook variants for verification. + +**Affected components:** Checkbox, CheckboxGroup From 251acf38d6d2f77d26ac8ad1b3491c7ddf1cc7f5 Mon Sep 17 00:00:00 2001 From: Charles de Dreuille Date: Thu, 23 Apr 2026 08:52:08 +0100 Subject: [PATCH 100/434] fix(ui): address further PR review comments - Replace custom UNSAFE_HREF_RE with @braintree/sanitize-url for robust XSS prevention - Shorten renderInlineMarkdown JSDoc - Single-user with href: collapse two adjacent links into one wrapping avatar + name - Multi-user list: use href ?? index:name as key to avoid collisions on duplicate names - Status dot: replace role="img"/aria-label with aria-hidden (text label is sufficient) Signed-off-by: Charles de Dreuille --- packages/ui/package.json | 1 + packages/ui/src/components/Header/Header.tsx | 22 ++----- .../Header/HeaderMetadataStatus.tsx | 3 +- .../components/Header/HeaderMetadataUsers.tsx | 59 +++++++++---------- yarn.lock | 8 +++ 5 files changed, 44 insertions(+), 49 deletions(-) diff --git a/packages/ui/package.json b/packages/ui/package.json index 0c43476e6a..84b3ada891 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -47,6 +47,7 @@ }, "dependencies": { "@backstage/version-bridge": "workspace:^", + "@braintree/sanitize-url": "^7.1.2", "@remixicon/react": "^4.6.0", "@tanstack/react-table": "^8.21.3", "clsx": "^2.1.1", diff --git a/packages/ui/src/components/Header/Header.tsx b/packages/ui/src/components/Header/Header.tsx index 5d41d6ca0f..07a9ec5ab7 100644 --- a/packages/ui/src/components/Header/Header.tsx +++ b/packages/ui/src/components/Header/Header.tsx @@ -20,32 +20,22 @@ import { RiArrowRightSLine } from '@remixicon/react'; import { HeaderNav } from './HeaderNav'; import { useDefinition } from '../../hooks/useDefinition'; import { HeaderDefinition } from './definition'; +import { sanitizeUrl } from '@braintree/sanitize-url'; import { Container } from '../Container'; import { Lexer } from 'marked'; import { Link } from '../Link'; import { Fragment } from 'react'; -// Reject javascript:/vbscript:/data: URIs to prevent XSS via description links. -const UNSAFE_HREF_RE = /^(javascript:|vbscript:|data:)/i; - /** - * Renders a plain-text string that may contain inline Markdown links as an - * array of React nodes (strings and Link elements). Links with unsafe URL - * schemes (javascript:, vbscript:, data:) are rendered as plain text instead. - * - * We use `marked`'s `Lexer.lexInline()` rather than `react-markdown` because - * `react-markdown` v8+ is ESM-only, which breaks Jest in Node-role packages - * that transitively import `@backstage/ui` (e.g. via `core-app-api`). `marked` - * ships CommonJS, has zero dependencies, and its inline lexer gives us a clean - * token model without needing to maintain a custom regex. + * Parses inline Markdown links in a string and returns an array of React nodes. + * URLs are sanitized via `@braintree/sanitize-url`; unsafe URLs are rendered as + * plain text. Uses `marked` instead of `react-markdown` to avoid ESM issues. */ function renderInlineMarkdown(text: string): React.ReactNode[] { return Lexer.lexInline(text).map((token, i) => { if (token.type === 'link') { - // Trim leading whitespace/control chars before scheme check to prevent - // bypass via inputs like " javascript:alert(1)". - const href = token.href.trimStart(); - if (UNSAFE_HREF_RE.test(href)) return token.text; + const href = sanitizeUrl(token.href); + if (href === 'about:blank') return token.text; return ( {token.text} diff --git a/packages/ui/src/components/Header/HeaderMetadataStatus.tsx b/packages/ui/src/components/Header/HeaderMetadataStatus.tsx index 2bf2effa8f..e39b58d74e 100644 --- a/packages/ui/src/components/Header/HeaderMetadataStatus.tsx +++ b/packages/ui/src/components/Header/HeaderMetadataStatus.tsx @@ -33,8 +33,7 @@ export const HeaderMetadataStatus = ({ return (
{customActions}
From 6c4606a71cfc19fdd00b60773680ec8a7df4b8fe Mon Sep 17 00:00:00 2001 From: Charles de Dreuille Date: Sun, 26 Apr 2026 15:05:45 +0100 Subject: [PATCH 126/434] Polish sticky Header implementation Signed-off-by: Charles de Dreuille --- .changeset/header-sticky-prop.md | 2 ++ packages/ui/src/components/Header/Header.module.css | 12 +++++++----- packages/ui/src/components/Header/Header.tsx | 8 ++++++-- 3 files changed, 15 insertions(+), 7 deletions(-) diff --git a/.changeset/header-sticky-prop.md b/.changeset/header-sticky-prop.md index 283c235d43..0e2722ed04 100644 --- a/.changeset/header-sticky-prop.md +++ b/.changeset/header-sticky-prop.md @@ -3,3 +3,5 @@ --- Added a `sticky` prop to the `Header` component. When `true`, the title-and-actions bar stays fixed to the top of its scroll container while the rest of the header (tags, description, metadata) scrolls away. The sticky bar background color automatically matches the container surface using the bg-consumer system. + +**Affected components:** Header diff --git a/packages/ui/src/components/Header/Header.module.css b/packages/ui/src/components/Header/Header.module.css index 0834767d2c..0d6e9f7612 100644 --- a/packages/ui/src/components/Header/Header.module.css +++ b/packages/ui/src/components/Header/Header.module.css @@ -116,8 +116,10 @@ .bui-HeaderTitleStack { position: relative; flex: 1 1 auto; - height: calc(var(--bui-font-size-6) * 1.4); + height: 1lh; min-width: 0; + font-size: var(--bui-font-size-6); + line-height: 140%; overflow: hidden; } @@ -151,13 +153,13 @@ overflow: hidden; font-family: var(--bui-font-regular); font-weight: var(--bui-font-weight-bold); - line-height: 140%; + line-height: inherit; text-overflow: ellipsis; white-space: nowrap; } .bui-HeaderBreadcrumbs .bui-HeaderBreadcrumbLink { - font-size: var(--bui-font-size-6); + font-size: inherit; } .bui-HeaderBreadcrumbsSmall .bui-HeaderBreadcrumbLinkSmall { @@ -176,14 +178,14 @@ min-width: 0; font-family: var(--bui-font-regular); font-weight: var(--bui-font-weight-bold); - line-height: 140%; + line-height: inherit; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .bui-HeaderTitle { - font-size: var(--bui-font-size-6); + font-size: inherit; } .bui-HeaderTitleSmall { diff --git a/packages/ui/src/components/Header/Header.tsx b/packages/ui/src/components/Header/Header.tsx index d46468799e..ad70508250 100644 --- a/packages/ui/src/components/Header/Header.tsx +++ b/packages/ui/src/components/Header/Header.tsx @@ -29,9 +29,11 @@ const getScrollParent = (element: HTMLElement | null): Element | null => { let parent = element?.parentElement; while (parent) { - const { overflowY } = window.getComputedStyle(parent); + const { overflow, overflowX, overflowY } = window.getComputedStyle(parent); - if (/(auto|scroll|overlay)/.test(overflowY)) { + if ( + /(auto|scroll|overlay|hidden)/.test(`${overflow}${overflowX}${overflowY}`) + ) { return parent; } @@ -85,6 +87,8 @@ export const Header = (props: HeaderProps) => { () => (description ? renderInlineMarkdown(description) : null), [description], ); + // The sentinel sits directly before the sticky content and leaves the + // viewport when the content becomes stuck, letting us toggle stuck styling. const stickySentinelRef = useRef(null); const [isStuck, setIsStuck] = useState(false); From 6b05c0a4de6f8b5bbd54f968790afcd1f7fb123d Mon Sep 17 00:00:00 2001 From: Charles de Dreuille Date: Sun, 26 Apr 2026 15:20:12 +0100 Subject: [PATCH 127/434] Address sticky Header review feedback Signed-off-by: Charles de Dreuille --- packages/ui/report.api.md | 1 - .../src/components/Header/Header.stories.tsx | 6 ++-- packages/ui/src/components/Header/Header.tsx | 32 ++++++++++++++++++- packages/ui/src/components/Header/types.ts | 4 +++ 4 files changed, 38 insertions(+), 5 deletions(-) diff --git a/packages/ui/report.api.md b/packages/ui/report.api.md index d49a512536..85f59f178f 100644 --- a/packages/ui/report.api.md +++ b/packages/ui/report.api.md @@ -1743,7 +1743,6 @@ export interface HeaderOwnProps { description?: string; // (undocumented) metadata?: HeaderMetadataItem[]; - // (undocumented) sticky?: boolean; // (undocumented) tabs?: HeaderNavTabItem[]; diff --git a/packages/ui/src/components/Header/Header.stories.tsx b/packages/ui/src/components/Header/Header.stories.tsx index b77a4c16c1..9cc715e471 100644 --- a/packages/ui/src/components/Header/Header.stories.tsx +++ b/packages/ui/src/components/Header/Header.stories.tsx @@ -24,7 +24,7 @@ import { MemoryRouter } from 'react-router-dom'; import { BUIProvider } from '../../provider'; import { Button, ButtonIcon, MenuTrigger, Menu, MenuItem } from '../../'; import { RiMore2Line } from '@remixicon/react'; -import { Container } from '../Container/Container'; +import { Container } from '../Container'; const meta = preview.meta({ title: 'Backstage UI/Header', @@ -415,8 +415,8 @@ export const NonSticky = meta.story({ {Array.from({ length: 60 }, (_, i) => (

- Scroll down to see the title bar stick to the top while the tags, - description, and metadata scroll away. Line {i + 1}. + Scroll down to see the entire header scroll away with the rest of + the page content. Line {i + 1}.

))}
diff --git a/packages/ui/src/components/Header/Header.tsx b/packages/ui/src/components/Header/Header.tsx index ad70508250..6e36d627f8 100644 --- a/packages/ui/src/components/Header/Header.tsx +++ b/packages/ui/src/components/Header/Header.tsx @@ -43,6 +43,16 @@ const getScrollParent = (element: HTMLElement | null): Element | null => { return null; }; +const isStickySentinelOutOfView = ( + sentinel: HTMLElement, + root: Element | null, +) => { + const sentinelRect = sentinel.getBoundingClientRect(); + const rootTop = root ? root.getBoundingClientRect().top : 0; + + return sentinelRect.bottom <= rootTop; +}; + /** * Parses inline Markdown links in a string and returns an array of React nodes. * URLs are sanitized via `@braintree/sanitize-url`; unsafe URLs are rendered as @@ -103,11 +113,31 @@ export const Header = (props: HeaderProps) => { return; } + const root = getScrollParent(sentinel); + + if (typeof IntersectionObserver === 'undefined') { + const updateStuckState = () => { + setIsStuck(isStickySentinelOutOfView(sentinel, root)); + }; + const scrollTarget = root ?? window; + + updateStuckState(); + scrollTarget.addEventListener('scroll', updateStuckState, { + passive: true, + }); + window.addEventListener('resize', updateStuckState); + + return () => { + scrollTarget.removeEventListener('scroll', updateStuckState); + window.removeEventListener('resize', updateStuckState); + }; + } + const observer = new IntersectionObserver( ([entry]) => { setIsStuck(!entry.isIntersecting); }, - { root: getScrollParent(sentinel), threshold: 0 }, + { root, threshold: 0 }, ); observer.observe(sentinel); diff --git a/packages/ui/src/components/Header/types.ts b/packages/ui/src/components/Header/types.ts index d126f40ce3..77fbe937c3 100644 --- a/packages/ui/src/components/Header/types.ts +++ b/packages/ui/src/components/Header/types.ts @@ -116,6 +116,10 @@ export interface HeaderOwnProps { tags?: HeaderTag[]; metadata?: HeaderMetadataItem[]; className?: string; + /** + * Makes the title-and-actions row stick to the top of its nearest scroll + * container while the rest of the header content scrolls away. + */ sticky?: boolean; } From fc6f0d98e00ff44ba795aae827d671ba4a4ba8c5 Mon Sep 17 00:00:00 2001 From: Charles de Dreuille Date: Sun, 26 Apr 2026 15:29:36 +0100 Subject: [PATCH 128/434] Remove display contents from sticky Header Signed-off-by: Charles de Dreuille --- .../src/components/Header/Header.module.css | 20 +- packages/ui/src/components/Header/Header.tsx | 241 ++++++++++-------- 2 files changed, 137 insertions(+), 124 deletions(-) diff --git a/packages/ui/src/components/Header/Header.module.css b/packages/ui/src/components/Header/Header.module.css index 0d6e9f7612..d8d0119771 100644 --- a/packages/ui/src/components/Header/Header.module.css +++ b/packages/ui/src/components/Header/Header.module.css @@ -25,20 +25,16 @@ padding-inline: var(--bui-space-5); } - .bui-Header[data-sticky] { - display: contents; - } - .bui-HeaderAfterSticky { display: flex; flex-direction: column; gap: var(--bui-space-3); } - .bui-Header[data-sticky] .bui-HeaderBeforeSticky, - .bui-Header[data-sticky] .bui-HeaderStickySentinel, - .bui-Header[data-sticky] .bui-HeaderContent, - .bui-Header[data-sticky] .bui-HeaderAfterSticky { + .bui-HeaderBeforeSticky[data-sticky], + .bui-HeaderStickySentinel[data-sticky], + .bui-HeaderContent[data-sticky], + .bui-HeaderAfterSticky[data-sticky] { width: 100%; padding-inline: var(--bui-space-5); box-sizing: border-box; @@ -48,7 +44,7 @@ padding-top: var(--bui-space-4); } - .bui-Header[data-sticky] .bui-HeaderBeforeSticky { + .bui-HeaderBeforeSticky[data-sticky] { padding-top: var(--bui-space-6); } @@ -72,15 +68,15 @@ z-index: 10; background-color: var(--bui-bg-app); - .bui-Header[data-on-bg='neutral-1'] & { + &[data-on-bg='neutral-1'] { background-color: var(--bui-bg-neutral-1); } - .bui-Header[data-on-bg='neutral-2'] & { + &[data-on-bg='neutral-2'] { background-color: var(--bui-bg-neutral-2); } - .bui-Header[data-on-bg='neutral-3'] & { + &[data-on-bg='neutral-3'] { background-color: var(--bui-bg-neutral-3); } } diff --git a/packages/ui/src/components/Header/Header.tsx b/packages/ui/src/components/Header/Header.tsx index 6e36d627f8..ab64dd6e7d 100644 --- a/packages/ui/src/components/Header/Header.tsx +++ b/packages/ui/src/components/Header/Header.tsx @@ -79,7 +79,9 @@ function renderInlineMarkdown(text: string): React.ReactNode[] { * @public */ export const Header = (props: HeaderProps) => { - const { ownProps, dataAttributes } = useDefinition(HeaderDefinition, props); + const { ownProps, dataAttributes } = useDefinition(HeaderDefinition, props, { + classNameTarget: props.sticky ? 'content' : 'root', + }); const { classes, title, @@ -147,63 +149,70 @@ export const Header = (props: HeaderProps) => { }; }, [sticky]); - return ( -
- {tags && tags.length > 0 && ( -
-
    - {tags.map((tag, i) => ( -
  • 0 && ( +
    +
      + {tags.map((tag, i) => ( +
    • + {tag.href ? ( + - {tag.href ? ( - - {tag.label} - - ) : ( - - {tag.label} - - )} -
    • + {tag.label} + + ) : ( + + {tag.label} + + )} + + ))} +
    +
    + ); + + const titleAndActionsContent = ( + <> +
    +
    + {breadcrumbs && + breadcrumbs.map(breadcrumb => ( + + + {breadcrumb.label} + + + ))} -
+

{title}

- )} - {sticky && ( -