diff --git a/packages/app/src/App.tsx b/packages/app/src/App.tsx
index 469f6a9f7..542bd8eba 100644
--- a/packages/app/src/App.tsx
+++ b/packages/app/src/App.tsx
@@ -8,6 +8,7 @@ import userSettingsPlugin from '@backstage/plugin-user-settings/alpha';
import catalogGraphPlugin from '@backstage/plugin-catalog-graph/alpha';
import kubernetesPlugin from '@backstage/plugin-kubernetes/alpha';
import apiDocsPlugin from '@backstage/plugin-api-docs/alpha';
+import notificationsPlugin from '@backstage/plugin-notifications/alpha';
import signalsPlugin from '@backstage/plugin-signals/alpha';
import githubActionsPlugin from '@backstage-community/plugin-github-actions/alpha';
import { createApp } from '@backstage/frontend-defaults';
@@ -41,6 +42,7 @@ const app = createApp({
gsPlugin,
scaffolderPlugin,
scaffolderPluginOverrides,
+ notificationsPlugin,
signalsPlugin,
// Upstream NFS plugins (pages provided by routeOverrides or defaults):
homePlugin,
diff --git a/packages/app/src/modules/nav/Sidebar.tsx b/packages/app/src/modules/nav/Sidebar.tsx
index 0de322be3..93cc70c1f 100644
--- a/packages/app/src/modules/nav/Sidebar.tsx
+++ b/packages/app/src/modules/nav/Sidebar.tsx
@@ -17,6 +17,7 @@ import {
UserSettingsSignInAvatar,
Settings as SidebarSettings,
} from '@backstage/plugin-user-settings';
+import { NotificationsSidebarItem } from '@backstage/plugin-notifications';
export const SidebarContent = NavContentBlueprint.make({
params: {
@@ -50,6 +51,7 @@ export const SidebarContent = NavContentBlueprint.make({
{nav.take('page:scaffolder')}
+
{
+ await sendReleaseNotification({
+ version,
+ notifications,
+ logger,
+ });
+ });
+ },
+ });
+ },
+});
diff --git a/plugins/notifications-backend-module-gs/src/releaseNotifier.test.ts b/plugins/notifications-backend-module-gs/src/releaseNotifier.test.ts
new file mode 100644
index 000000000..59b896e22
--- /dev/null
+++ b/plugins/notifications-backend-module-gs/src/releaseNotifier.test.ts
@@ -0,0 +1,102 @@
+import { sendReleaseNotification } from './releaseNotifier';
+import { NotificationService } from '@backstage/plugin-notifications-node';
+import { LoggerService } from '@backstage/backend-plugin-api';
+
+function createMockLogger(): jest.Mocked {
+ return {
+ info: jest.fn(),
+ warn: jest.fn(),
+ error: jest.fn(),
+ debug: jest.fn(),
+ child: jest.fn().mockReturnThis(),
+ };
+}
+
+function createMockNotifications(): jest.Mocked {
+ return {
+ send: jest.fn(),
+ };
+}
+
+describe('sendReleaseNotification', () => {
+ let logger: jest.Mocked;
+ let notifications: jest.Mocked;
+
+ beforeEach(() => {
+ logger = createMockLogger();
+ notifications = createMockNotifications();
+ jest.clearAllMocks();
+ });
+
+ it('skips when version is empty', async () => {
+ await sendReleaseNotification({ version: '', notifications, logger });
+
+ expect(notifications.send).not.toHaveBeenCalled();
+ expect(logger.warn).toHaveBeenCalledWith(
+ expect.stringContaining('not available'),
+ );
+ });
+
+ it('skips when version is unparseable', async () => {
+ await sendReleaseNotification({
+ version: 'not-a-version',
+ notifications,
+ logger,
+ });
+
+ expect(notifications.send).not.toHaveBeenCalled();
+ expect(logger.warn).toHaveBeenCalledWith(
+ expect.stringContaining('could not parse'),
+ );
+ });
+
+ it('skips patch releases', async () => {
+ await sendReleaseNotification({
+ version: '0.115.1',
+ notifications,
+ logger,
+ });
+
+ expect(notifications.send).not.toHaveBeenCalled();
+ expect(logger.info).toHaveBeenCalledWith(
+ expect.stringContaining('patch release'),
+ );
+ });
+
+ it('sends notification for minor release with link', async () => {
+ await sendReleaseNotification({
+ version: '0.115.0',
+ notifications,
+ logger,
+ });
+
+ expect(notifications.send).toHaveBeenCalledWith({
+ recipients: { type: 'broadcast' },
+ payload: {
+ title: 'Portal updated to v0.115.0',
+ description: 'Click to view the release notes.',
+ link: 'https://github.com/giantswarm/backstage/releases/tag/v0.115.0',
+ severity: 'normal',
+ topic: 'release',
+ scope: 'release-v0.115.0',
+ },
+ });
+ });
+
+ it('sends notification for major release', async () => {
+ await sendReleaseNotification({
+ version: '1.0.0',
+ notifications,
+ logger,
+ });
+
+ expect(notifications.send).toHaveBeenCalledWith({
+ recipients: { type: 'broadcast' },
+ payload: expect.objectContaining({
+ title: 'Portal updated to v1.0.0',
+ link: 'https://github.com/giantswarm/backstage/releases/tag/v1.0.0',
+ scope: 'release-v1.0.0',
+ }),
+ });
+ });
+});
diff --git a/plugins/notifications-backend-module-gs/src/releaseNotifier.ts b/plugins/notifications-backend-module-gs/src/releaseNotifier.ts
new file mode 100644
index 000000000..0008445ec
--- /dev/null
+++ b/plugins/notifications-backend-module-gs/src/releaseNotifier.ts
@@ -0,0 +1,55 @@
+import semver from 'semver';
+import { LoggerService } from '@backstage/backend-plugin-api';
+import { NotificationService } from '@backstage/plugin-notifications-node';
+
+const RELEASE_URL_BASE = 'https://github.com/giantswarm/backstage/releases/tag';
+
+export interface ReleaseNotifierOptions {
+ version: string;
+ notifications: NotificationService;
+ logger: LoggerService;
+}
+
+export async function sendReleaseNotification(
+ options: ReleaseNotifierOptions,
+): Promise {
+ const { version, notifications, logger } = options;
+
+ if (!version) {
+ logger.warn('Release notifier: app version not available, skipping.');
+ return;
+ }
+
+ const parsed = semver.parse(version);
+ if (!parsed) {
+ logger.warn(
+ `Release notifier: could not parse version "${version}", skipping.`,
+ );
+ return;
+ }
+
+ if (parsed.patch !== 0) {
+ logger.info(
+ `Release notifier: patch release v${version} — skipping notification.`,
+ );
+ return;
+ }
+
+ const scope = `release-v${parsed.major}.${parsed.minor}.0`;
+
+ logger.info(
+ `Release notifier: sending broadcast notification for v${version}.`,
+ );
+
+ await notifications.send({
+ recipients: { type: 'broadcast' },
+ payload: {
+ title: `Portal updated to v${version}`,
+ description: 'Click to view the release notes.',
+ link: `${RELEASE_URL_BASE}/v${version}`,
+ severity: 'normal',
+ topic: 'release',
+ scope,
+ },
+ });
+}
diff --git a/yarn.lock b/yarn.lock
index e4b65bbd4..da0d83c5b 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -7107,7 +7107,7 @@ __metadata:
languageName: node
linkType: hard
-"@backstage/plugin-notifications-node@npm:^0.2.24":
+"@backstage/plugin-notifications-node@backstage:^::backstage=1.49.2&npm=0.2.24, @backstage/plugin-notifications-node@npm:^0.2.24":
version: 0.2.24
resolution: "@backstage/plugin-notifications-node@npm:0.2.24"
dependencies:
@@ -10109,6 +10109,19 @@ __metadata:
languageName: unknown
linkType: soft
+"@giantswarm/backstage-plugin-notifications-backend-module-gs@workspace:^, @giantswarm/backstage-plugin-notifications-backend-module-gs@workspace:plugins/notifications-backend-module-gs":
+ version: 0.0.0-use.local
+ resolution: "@giantswarm/backstage-plugin-notifications-backend-module-gs@workspace:plugins/notifications-backend-module-gs"
+ dependencies:
+ "@backstage/backend-plugin-api": "backstage:^"
+ "@backstage/backend-test-utils": "backstage:^"
+ "@backstage/cli": "backstage:^"
+ "@backstage/plugin-notifications-node": "backstage:^"
+ "@types/semver": "npm:^7"
+ semver: "npm:^7.7.3"
+ languageName: unknown
+ linkType: soft
+
"@giantswarm/backstage-plugin-scaffolder-backend-module-gs@npm:^0.11.0, @giantswarm/backstage-plugin-scaffolder-backend-module-gs@workspace:plugins/scaffolder-backend-module-gs":
version: 0.0.0-use.local
resolution: "@giantswarm/backstage-plugin-scaffolder-backend-module-gs@workspace:plugins/scaffolder-backend-module-gs"
@@ -23554,6 +23567,7 @@ __metadata:
"@giantswarm/backstage-plugin-auth-backend-module-gs": "npm:^0.13.0"
"@giantswarm/backstage-plugin-catalog-backend-module-gs": "npm:^0.2.0"
"@giantswarm/backstage-plugin-gs-backend": "npm:^0.6.0"
+ "@giantswarm/backstage-plugin-notifications-backend-module-gs": "workspace:^"
"@giantswarm/backstage-plugin-scaffolder-backend-module-gs": "npm:^0.11.0"
"@giantswarm/backstage-plugin-techdocs-backend-module-gs": "npm:^0.10.0"
"@internal/backend-common": "workspace:^"