PlatformEngr® Backstage course

Module 07 · Golden paths and docs · 60 min · You are: Platform team

Your own scaffolder action

In this module

  • You learn: How a backend module extends a plugin through an extension point, how an action declares its input and output, and how to test it.
  • You do: Generate a scaffolder module with yarn new, write the action in TypeScript, test it, and call it from the template.
  • You end with: The scaffolder has your acme:team:lookup action with three passing tests. New services say "Owned by Team Checkout" in their README and docs.

Built-in actions cover files, Git and the catalog. Company rules (naming, owners, approvals, internal systems) need your own code. A scaffolder module is the smallest piece of backend code you can write: it plugs into one plugin through an extension point and gets services like the catalog injected.

Open this module in the interactive course to track progress.

Concepts

Backend module
Code that extends one plugin (here scaffolder) and runs inside it. Created with createBackendModule, added with backend.add.
Extension point
What a plugin offers modules. scaffolderActionsExtensionPoint.addActions() registers actions.
Service ref
A dependency the backend injects: catalogServiceRef, loggerServiceRef, databaseServiceRef. You list them in deps.
Action schema
Zod schemas for input and output. The template editor's Actions page shows them.

Files to know

Open them in your editor or on GitHub; the bullets say what to look at.

Steps

1. Generate the module

yarn new creates the package and wires it into the backend's package.json and index.ts.

cd ~/work/portal && yarn new --select scaffolder-backend-module --option moduleId=acme

Expect a new plugins/scaffolder-backend-module-acme folder and a new last line in the backend index.ts:

tail -n 3 packages/backend/src/index.ts
yarn --cwd plugins/scaffolder-backend-module-acme add @backstage/plugin-catalog-node@^2.2.6 @backstage/errors@^1.3.2

2. Write the action

Replace the generated example with the real action.

rm ~/work/portal/plugins/scaffolder-backend-module-acme/src/actions/example.ts ~/work/portal/plugins/scaffolder-backend-module-acme/src/actions/example.test.ts

Create ~/work/portal/plugins/scaffolder-backend-module-acme/src/actions/teamLookup.ts

import { CatalogService } from '@backstage/plugin-catalog-node';
import { createTemplateAction } from '@backstage/plugin-scaffolder-node';
import { InputError } from '@backstage/errors';

// acme:team:lookup checks that the owner picked in a template is a real team
// and returns its display name for the skeleton to use.
export function createTeamLookupAction(options: { catalog: CatalogService }) {
  const { catalog } = options;

  return createTemplateAction({
    id: 'acme:team:lookup',
    description: 'Checks that the owner is a team and returns its name',
    // Read-only, so it is safe to run in the template editor's dry run.
    supportsDryRun: true,
    schema: {
      input: {
        owner: z =>
          z.string({
            description:
              'Entity ref of the owner, e.g. group:default/team-checkout',
          }),
      },
      output: {
        title: z => z.string({ description: 'Display name of the team' }),
      },
    },
    async handler(ctx) {
      const entity = await catalog.getEntityByRef(ctx.input.owner, {
        credentials: await ctx.getInitiatorCredentials(),
      });

      if (entity?.kind !== 'Group' || entity.spec?.type !== 'team') {
        throw new InputError(`${ctx.input.owner} is not a team in the catalog`);
      }

      const title = entity.metadata.title ?? entity.metadata.name;
      ctx.logger.info(`Owner is ${title}`);
      ctx.output('title', title);
    },
  });
}

Replace the whole file ~/work/portal/plugins/scaffolder-backend-module-acme/src/module.ts

import { createBackendModule } from '@backstage/backend-plugin-api';
import { catalogServiceRef } from '@backstage/plugin-catalog-node';
import { scaffolderActionsExtensionPoint } from '@backstage/plugin-scaffolder-node';
import { createTeamLookupAction } from './actions/teamLookup';

export const scaffolderModuleAcme = createBackendModule({
  pluginId: 'scaffolder',
  moduleId: 'acme',
  register({ registerInit }) {
    registerInit({
      deps: {
        scaffolderActions: scaffolderActionsExtensionPoint,
        catalog: catalogServiceRef,
      },
      async init({ scaffolderActions, catalog }) {
        scaffolderActions.addActions(createTeamLookupAction({ catalog }));
      },
    });
  },
});

Change in ~/work/portal/plugins/scaffolder-backend-module-acme/src/index.ts

- export { scaffolderModule as default } from './module';
+ export { scaffolderModuleAcme as default } from './module';

3. Test it

Copy the tests: they fake the catalog and check the three cases.

cp ~/work/backstage-workshop/portal/plugins/scaffolder-backend-module-acme/src/actions/teamLookup.test.ts ~/work/portal/plugins/scaffolder-backend-module-acme/src/actions/

Expect Tests: 3 passed:

cd ~/work/portal/plugins/scaffolder-backend-module-acme && yarn test --watchAll=false

And the types for the whole repo. Expect no output:

cd ~/work/portal && yarn tsc

4. Use it in the template

The lookup runs before anything is created, so a bad owner fails fast.

Add to ~/work/portal/templates/node-service/template.yaml under steps:, before - id: fetch

    - id: lookup
      name: Check the owner
      action: acme:team:lookup
      input:
        owner: ${{ parameters.owner }}

Change in ~/work/portal/templates/node-service/template.yaml

-           ownerTitle: ${{ parameters.owner }}
+           ownerTitle: ${{ steps['lookup'].output.title }}

Restart the portal (Ctrl+C, then yarn start). Then: 1. Create → ⋮ → Installed Actions: find acme:team:lookup with its input and output. 2. Dry-run the template in the editor (reload the directory). Log shows Owner is Team Checkout; in Files, README.md says Owned by Team Checkout.

5. Ship it

cd ~/work/portal && git status --short

Propose it as a pull request. Put it on its own branch and push it:

git switch -c action-team-lookup && git add -A && git commit -qm "Scaffolder: acme:team:lookup action"
git push -qu origin HEAD

Open the pull request. It prints a link:

gh pr create --fill

Open that link in your browser. Files changed shows exactly your diff.

Merge it on GitHub (Squash and merge, then Delete branch), or from the terminal:

gh pr merge --squash --delete-branch

Either way, bring your copy up to date:

git switch -q main && git pull -q

Simplified for the course

The module lives in the portal repo. When several portals or teams need it, publish it as a package and version it.

Break it on purpose: The dry run stops checking

A platform engineer "simplifies" the action by deleting the lines they do not recognise. Tests and types still pass.

You practise: how dry run treats your actions, and why an action declares what it is safe to do.

1. Break it

Delete from ~/work/portal/plugins/scaffolder-backend-module-acme/src/actions/teamLookup.ts

-     // Read-only, so it is safe to run in the template editor's dry run.
-     supportsDryRun: true,

Restart the portal and dry-run the template again.

2. What you see

Expected: Owner is Team Checkout in the log and in the README. You get: the log says Skipping because acme:team:lookup does not support dry-run, and the README says Owned by ****.

3. Fix it yourself

Make the dry run run the lookup again, and say why it is safe to.

Hint 1

The log message names the cause. What does an action have to declare to run in a dry run?

Hint 2

Look at the createTemplateAction options in the reference teamLookup.ts.

The fix

Add to ~/work/portal/plugins/scaffolder-backend-module-acme/src/actions/teamLookup.ts after description:

    // Read-only, so it is safe to run in the template editor's dry run.
    supportsDryRun: true,

Safe because the action only reads. An action that writes must check ctx.isDryRun and skip the write.

After you fixed it: the five questions
What failed?
The action no longer declared dry-run support, so the scaffolder skipped it and later steps got no output.
How was it detected?
In the dry run's log and rendered files.
Who responds?
The platform team: they own the action.
How is it corrected?
Declare supportsDryRun: true again.
How is it prevented?
A test that asserts supportsDryRun, and a CI dry run of the template that checks the rendered README.

Quiz

  1. How does your module get the catalog client?
    • It lists catalogServiceRef in deps and the backend injects it
    • It creates an HTTP client with the backend URL and a token
    • It imports the catalog plugin's database directly
  2. Why is the action a module of the scaffolder and not its own plugin?
    • Plugins cannot be written in TypeScript
    • It extends the scaffolder through its extension point and has no API of its own
    • Modules start faster
  3. Why does the action read the catalog with ctx.getInitiatorCredentials()?
    • The catalog is not reachable otherwise
    • It is faster than a service token
    • So the read happens as the user who runs the template, and permissions apply to them
Answers
  1. It lists catalogServiceRef in deps and the backend injects it Services are injected through refs; that is how auth and plugin boundaries are kept.
  2. It extends the scaffolder through its extension point and has no API of its own Plugins own features and HTTP routes; modules extend one plugin.
  3. So the read happens as the user who runs the template, and permissions apply to them On whose behalf matters once permissions are on.

Field note

The first custom action every company writes is a lookup. Keep it read-only and dry-run safe; put the writes in actions that only run for real.

Report a problem with this module