Skip to main content

Writing Custom Actions

If you want to extend the functionality of the Scaffolder, you can do so by writing custom actions which can be used alongside our built-in actions.

Note

When adding custom actions, the actions array will replace the built-in actions too. Meaning, you will no longer be able to use them. If you want to continue using the builtin actions, include them in the actions array when registering your custom actions, as seen below.

Streamlining Custom Action Creation with Backstage CLI

The creation of custom actions in Backstage has never been easier thanks to the Backstage CLI. This tool streamlines the setup process, allowing you to focus on your actions' unique functionality.

Start by using the yarn backstage-cli new command to generate a scaffolder module. This command sets up the necessary boilerplate code, providing a smooth start:

$ yarn backstage-cli new
? What do you want to create?
plugin-common - A new isomorphic common plugin package
plugin-node - A new Node.js library plugin package
plugin-react - A new web library plugin package
> scaffolder-module - An module exporting custom actions for @backstage/plugin-scaffolder-backend

You can find a list of all commands provided by the Backstage CLI.

When prompted, select the option to generate a scaffolder module. This creates a solid foundation for your custom action. Enter the name of the module you wish to create, and the CLI will generate the required files and directory structure.

Writing your Custom Action

After running the command, the CLI will create a new directory with your new scaffolder module. This directory will be the working directory for creating the custom action. It will contain all the necessary files and boilerplate code to get started.

Let's create a simple action that adds a new file and some contents that are passed as input to the function. Within the generated directory, locate the file at src/actions/example/example.ts. Feel free to rename this file along with its generated unit test. We will replace the existing placeholder code with our custom action code as follows:

With Zod
import { createTemplateAction } from '@backstage/plugin-scaffolder-node';
import fs from 'fs-extra';
import { z } from 'zod';

export const createNewFileAction = () => {
return createTemplateAction({
id: 'acme:file:create',
description: 'Create an Acme file.',
schema: {
input: z.object({
contents: z.string().describe('The contents of the file'),
filename: z
.string()
.describe('The filename of the file that will be created'),
}),
},

async handler(ctx) {
await fs.outputFile(
`${ctx.workspacePath}/${ctx.input.filename}`,
ctx.input.contents,
);
},
});
};

So let's break this down. The createNewFileAction is a function that returns a createTemplateAction, and it's a good place to pass in dependencies which close over the TemplateAction. Take a look at our built-in actions for reference.

The createTemplateAction takes an object which specifies the following:

  • id - A unique ID for your custom action. We encourage you to namespace these in some way so that they won't collide with future built-in actions that we may ship with the scaffolder-backend plugin.
  • description - An optional field to describe the purpose of the action. This will populate in the /create/actions endpoint.
  • schema.input - A zod or JSON schema object for input values to your function
  • schema.output - A zod or JSON schema object for values which are output from the function using ctx.output
  • handler - the actual code which is run as part of the action, with a context

You can also choose to define your custom action using JSON schema instead of zod:

With JSON Schema
import { createTemplateAction } from '@backstage/plugin-scaffolder-node';
import { writeFile } from 'fs';

export const createNewFileAction = () => {
return createTemplateAction<{ contents: string; filename: string }>({
id: 'acme:file:create',
description: 'Create an Acme file.',
schema: {
input: {
required: ['contents', 'filename'],
type: 'object',
properties: {
contents: {
type: 'string',
title: 'Contents',
description: 'The contents of the file',
},
filename: {
type: 'string',
title: 'Filename',
description: 'The filename of the file that will be created',
},
},
},
},
async handler(ctx) {
const { signal } = ctx;
await writeFile(
`${ctx.workspacePath}/${ctx.input.filename}`,
ctx.input.contents,
{ signal },
_ => {},
);
},
});
};

Naming Conventions

Try to keep names consistent for both your own custom actions, and any actions contributed to open source. We've found that a separation of : and using a verb as the last part of the name works well. We follow provider:entity:verb or as close to this as possible for our built in actions. For example, github:actions:create or github:repo:create.

Also feel free to use your company name to namespace them if you prefer too, for example acme:file:create like above.

Prefer to use camelCase over snake_case or kebab-case for these actions if possible, which leads to better reading and writing of template entity definitions.

We're aware that there are some exceptions to this, but try to follow as close as possible. We'll be working on migrating these in the repository over time too.

The context object

When the action handler is called, we provide you a context as the only argument. It looks like the following:

  • ctx.baseUrl - a string where the template is located
  • ctx.checkpoint - Experimental allows to implement idempotency of the actions by not re-running the same function again if it was executed successfully on the previous run.
  • ctx.logger - a Winston logger for additional logging inside your action
  • ctx.logStream - a stream version of the logger if needed
  • ctx.workspacePath - a string of the working directory of the template run
  • ctx.input - an object which should match the zod or JSON schema provided in the schema.input part of the action definition
  • ctx.output - a function which you can call to set outputs that match the JSON schema or zod in schema.output for ex. ctx.output('downloadUrl', myDownloadUrl)
  • createTemporaryDirectory a function to call to give you a temporary directory somewhere on the runner, so you can store some files there rather than polluting the workspacePath
  • ctx.metadata - an object containing a name field, indicating the template name. More metadata fields may be added later.

Registering Custom Actions

To register your new custom action in the Backend System you will need to create a backend module. Here is a very simplified example of how to do that:

packages/backend/src/index.ts
import { scaffolderActionsExtensionPoint } from '@backstage/plugin-scaffolder-node/alpha';
import { createBackendModule } from '@backstage/backend-plugin-api';

const scaffolderModuleCustomExtensions = createBackendModule({
pluginId: 'scaffolder', // name of the plugin that the module is targeting
moduleId: 'custom-extensions',
register(env) {
env.registerInit({
deps: {
scaffolder: scaffolderActionsExtensionPoint,
// ... and other dependencies as needed
},
async init({ scaffolder /* ..., other dependencies */ }) {
// Here you have the opportunity to interact with the extension
// point before the plugin itself gets instantiated
scaffolder.addActions(new createNewFileAction()); // just an example
},
});
},
});

const backend = createBackend();
backend.add(import('@backstage/plugin-scaffolder-backend/alpha'));
backend.add(scaffolderModuleCustomExtensions);

If your custom action requires core services such as config or cache they can be imported in the dependencies and passed to the custom action function.

packages/backend/src/index.ts
import {
coreServices,
createBackendModule,
} from '@backstage/backend-plugin-api';

...

env.registerInit({
deps: {
scaffolder: scaffolderActionsExtensionPoint,
cache: coreServices.cache,
config: coreServices.rootConfig,
},
async init({ scaffolder, cache, config }) {
scaffolder.addActions(
customActionNeedingCacheAndConfig({ cache: cache, config: config }),
);
})

Using Checkpoints in Custom Actions (Experimental)

Idempotent action could be achieved via the usage of checkpoints.

Example:

plugins/my-company-scaffolder-actions-plugin/src/vendor/my-custom-action.ts
const res = await ctx.checkpoint?.('create.projects', async () => {
const projectStgId = createStagingProjectId();
const projectProId = createProductionProjectId();

return {
projectStgId,
projectProId,
};
});

You have to define the unique key in scope of the scaffolder task for your checkpoint. During the execution task engine will check if the checkpoint with such key was already executed or not, if yes, and the run was successful, the callback will be skipped and instead the stored value will be returned.

Register Custom Actions with the Legacy Backend System

Once you have your Custom Action ready for usage with the scaffolder, you'll need to pass this into the scaffolder-backend createRouter function. You should have something similar to the below in packages/backend/src/plugins/scaffolder.ts

return await createRouter({
catalogClient,
logger: env.logger,
config: env.config,
database: env.database,
reader: env.reader,
});

There's another property you can pass here, which is an array of actions which will set the available actions that the scaffolder has access to.

import { createBuiltinActions } from '@backstage/plugin-scaffolder-backend';
import { ScmIntegrations } from '@backstage/integration';
import { createNewFileAction } from './scaffolder/actions/custom';

export default async function createPlugin(
env: PluginEnvironment,
): Promise<Router> {
const catalogClient = new CatalogClient({ discoveryApi: env.discovery });
const integrations = ScmIntegrations.fromConfig(env.config);

const builtInActions = createBuiltinActions({
integrations,
catalogClient,
config: env.config,
reader: env.reader,
});

const actions = [...builtInActions, createNewFileAction()];

return createRouter({
actions,
catalogClient: catalogClient,
logger: env.logger,
config: env.config,
database: env.database,
reader: env.reader,
});
}

List of custom action packages

Here is a list of Open Source custom actions that you can add to your Backstage scaffolder backend:

NamePackageOwner
Yeomanplugin-scaffolder-backend-module-yeomanBackstage
Cookiecutterplugin-scaffolder-backend-module-cookiecutterBackstage
Railsplugin-scaffolder-backend-module-railsBackstage
HTTP requestsscaffolder-backend-module-http-requestRoadie
Utility actionsscaffolder-backend-module-utilsRoadie
AWS cli actionsscaffolder-backend-module-awsRoadie
Scaffolder .NET Actionsplugin-scaffolder-dotnet-backendAlef Carlos
Scaffolder Git Actionsplugin-scaffolder-git-actionsDrew Hill
Azure Pipeline Actionsscaffolder-backend-module-azure-pipelinesParfümerie Douglas
Azure Repository Actionsscaffolder-backend-module-azure-repositoriesParfümerie Douglas
Snyk Import Projectplugin-scaffolder-backend-module-snykMatthew Thomas
JSON Merge Actionsplugin-scaffolder-json-merge-actionsDrew Hill
NPM Actionsplugin-scaffolder-npm-actionsDrew Hill
Slack Actionsplugin-scaffolder-backend-module-slackDrew Hill
Microsoft Teams Actionsplugin-scaffolder-backend-module-ms-teamsGaurav Pandey

Have fun! 🚀