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:lookupaction 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 withcreateBackendModule, added withbackend.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 indeps. - 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.
portal/plugins/scaffolder-backend-module-acme/src/module.ts(reference, your repo): Registers the action with the scaffolder.portal/plugins/scaffolder-backend-module-acme/src/actions/teamLookup.ts(reference, your repo): The action.portal/plugins/scaffolder-backend-module-acme/src/actions/teamLookup.test.ts(reference, your repo): Unit tests with a mock catalog.- The rule, tested L33–39
portal/templates/node-service/template.yaml(reference, your repo): The template, now calling the action.
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: trueagain. - How is it prevented?
- A test that asserts
supportsDryRun, and a CI dry run of the template that checks the rendered README.
Quiz
- How does your module get the catalog client?
- It lists
catalogServiceRefindepsand the backend injects it - It creates an HTTP client with the backend URL and a token
- It imports the catalog plugin's database directly
- It lists
- 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
- 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
- It lists
catalogServiceRefindepsand the backend injects it Services are injected through refs; that is how auth and plugin boundaries are kept. - 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.
- 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.