Building Generators
Actions Reference
Actions are reusable, atomic units of work. Steps in a workflow reference them with uses:. Built-in actions ship with xo (xo/ prefix); custom actions live in your generator repo (./) or on GitHub (@github/).
Using an action
Assign id: to any step to capture its outputs and reference them in later steps via {{ steps.<id>.outputs.<key> }}.
steps:
- uses: xo/install-pkg # built-in
with:
pkg: stripe
- uses: xo/detect-pm # built-in — produces outputs
id: pm
- uses: ./actions/setup.yaml # custom composite action
id: setup
with:
configFile: tailwind.config.ts
- run: "{{ steps.pm.outputs.value }} db:push"Step execution
Step outputs
Any step with id: exposes its results as {{ steps.<id>.outputs.<key> }} in subsequent steps.
- uses: xo/detect-pm
id: pm
- run: "{{ steps.pm.outputs.value }} install"Parallel steps
Add parallel: true to steps that can safely run concurrently. Consecutive parallel steps form a batch executed via Promise.all. The batch completes before the next non-parallel step begins.
steps:
- uses: xo/detect-pm
id: pm
parallel: true
- uses: xo/detect-lang
id: lang
parallel: true
- uses: xo/file-exists # waits for both above to finish
id: hasPrisma
with:
path: prisma/schema.prismaDetection actions
Detection actions inspect the project without modifying it. They always run — even in --dry-run mode — because they have no side effects. Assign an id to read their outputs in later steps.
Detects the package manager from lockfile presence.
- uses: xo/detect-pm
id: pm
# outputs: { value: "pnpm" | "npm" | "yarn" | "bun" }
- run: "{{ steps.pm.outputs.value }} add stripe"Detects the primary language of the project.
- uses: xo/detect-lang
id: lang
# outputs: { value: "typescript" | "javascript" | "dart" | "go" | "rust" | "python" }
- if: "steps.lang.outputs.value == 'typescript'"
uses: xo/copy
with:
from: templates/config.ts
to: src/config.tsChecks whether a file or directory exists at a path relative to the project root.
- uses: xo/file-exists
id: hasPrisma
with:
path: prisma/schema.prisma
# outputs: { exists: true | false }
- if: "steps.hasPrisma.outputs.exists == false"
run: pnpm dlx prisma initChecks whether a package is in dependencies, devDependencies, or peerDependencies.
- uses: xo/pkg-installed
id: hasNext
with:
pkg: next
# outputs: { installed: true | false, version: "^14.0.0" | null }
- if: "steps.hasNext.outputs.installed == true"
uses: xo/copy
with:
from: templates/next-route.ts
to: app/api/stripe/route.tsReads a value from a JSON file at a dot-notation path.
- uses: xo/read-json
id: pkgName
with:
file: package.json
path: name
# outputs: { value: "my-app" }File actions
Copies a file, directory, or glob match from the generator to the project. For remote generators (GitHub), directories are enumerated via the GitHub Contents API. For local generators, fs.copy handles them natively.
# Single file
- uses: xo/copy
with:
from: templates/button.tsx
to: src/components/button.tsx
# Entire directory (recursive)
- uses: xo/copy
with:
from: templates/components/
to: src/components/
# Glob pattern — supports *, **, ?
- uses: xo/copy
with:
from: "lib/**/*.dart"
to: lib/* matches any characters within a path segment, ** matches across directory separators, ? matches a single character. When from has no glob characters and points at a directory, the whole directory is copied recursively.Renders a Handlebars template from the generator and writes the output. Supports {{ inputs.* }}, {{ steps.*.outputs.* }}, and all Handlebars helpers.
- uses: xo/template
with:
from: templates/service.hbs
to: "src/{{ inputs.moduleName }}/{{ inputs.moduleName }}.service.ts"Appends content to a file. Creates the file if it does not exist.
- uses: xo/append
with:
file: .env.example
content: "STRIPE_SECRET_KEY=\nSTRIPE_WEBHOOK_SECRET=\n"Inserts content immediately after or before a marker string in an existing file. Throws if the marker is not found. Exactly one of after or before is required.
# After a marker
- uses: xo/inject
with:
file: app.module.ts
after: "imports: ["
content: "PaymentModule,"
# Before a marker
- uses: xo/inject
with:
file: src/index.ts
before: "export default app"
content: "import './payment';"Finds a regex pattern in a file and replaces it. The search field is a regex string.
- uses: xo/replace
with:
file: config.ts
find: "DEBUG=false"
replace: "DEBUG=true"Reads a JSON file, sets a value at a dot-notation path, and writes it back. Deep paths are created if missing.
- uses: xo/json
with:
file: package.json
path: dependencies.stripe
value: "^14.0.0"Adds or updates variables in a .env file. Skips any key that already exists.
- uses: xo/env
with:
file: .env.example
variables:
STRIPE_SECRET_KEY: ""
STRIPE_WEBHOOK_SECRET: ""Prompt actions
Displays a message or requests confirmation from the user mid-workflow. Use type: info or type: warn for status messages, and type: confirm to gate steps behind a yes/no answer.
- uses: xo/prompt
with:
type: info
message: "Setting up your Flutter project..."- uses: xo/prompt
with:
type: warn
message: "This will overwrite your existing pubspec.yaml"- id: proceed
uses: xo/prompt
with:
type: confirm
message: "Install heavy dependencies? (~200MB)"
default: true
- if: "steps.proceed.outputs.confirmed == true"
uses: xo/install-pkg
with:
pkg: some-heavy-pkg| type | Description | Outputs |
|---|---|---|
| info | Prints an informational message | — |
| warn | Prints a warning message | — |
| success | Prints a success message | — |
| confirm | Asks a yes/no question | { confirmed: boolean } |
Code actions
Adds an import statement to a TypeScript/JavaScript file using AST manipulation (ts-morph). No-ops if the import already exists.
- uses: xo/ast-import
with:
file: src/app.module.ts
import: PaymentModule
from: ./payment/payment.modulePackage actions
Installs a package using the auto-detected package manager. If a xo/detect-pm step with id: pm ran earlier in the workflow, its output is used directly. Skips if the package is already installed.
- uses: xo/install-pkg
with:
pkg: stripe
dev: falseExecution actions
Runs a shell command in the project root. The inline run: shorthand is equivalent and preferred.
# Inline shorthand
- run: pnpm db:push
# As an action
- uses: xo/run
with:
command: pnpm db:pushRuns a shell script file from the generator's directory.
- uses: xo/script
with:
file: scripts/post-install.shCustom actions
Package reusable logic as action.yaml files. Reference them locally with uses: ./actions/name.yaml or share them on GitHub with uses: @github/owner/repo.
Composite action (YAML)
A YAML file with steps: that composes xo/* built-ins. No code needed. Only values declared under outputs: are visible to the parent workflow.
# actions/add-barrel.yaml
name: add-barrel-export
description: Append a barrel export to an index file
inputs:
exportName:
prompt: "Export name?"
filePath:
prompt: "Source file path (without extension)?"
outputs:
exportLine:
value: "export { default as {{ inputs.exportName }} } from './{{ inputs.filePath }}';"
steps:
- uses: xo/append
with:
file: src/components/index.ts
content: "export { default as {{ inputs.exportName }} } from './{{ inputs.filePath }}';"- uses: ./actions/add-barrel.yaml
id: barrel
with:
exportName: "{{ inputs.componentName }}"
filePath: "{{ inputs.componentName }}"
- run: echo "Added: {{ steps.barrel.outputs.exportLine }}"Script action (JS)
A plain .js file that exports an async function. Full Node.js — use any logic, call external APIs, read files, return outputs. The function receives { inputs, cwd, generatorDir, dryRun, env } and returns a plain object whose keys become step outputs.
// actions/log-info.js
export default async function run({ inputs, cwd, generatorDir, dryRun, env }) {
const msg = `component "${inputs.componentName}" added to ${cwd}`;
if (!dryRun) console.log(` ✔ ${msg}`);
return { message: msg };
}- uses: ./actions/log-info.js
id: logger
with:
componentName: "{{ inputs.componentName }}"
- run: echo "{{ steps.logger.outputs.message }}"Prompt action — reusable input sets
An action.yaml with runs.using: prompt collects inputs interactively and exposes the answers as step outputs — no steps required. Works with local ./actions/ references, @github/ sharing, and pinned tags. Usewith: to pre-fill or override specific inputs.
# actions/app-naming.yaml
name: app-naming
description: Reusable app naming prompt set
runs:
using: prompt
inputs:
appName:
prompt: "App display name?"
type: text
required: true
projectName:
prompt: "Project name (snake_case)?"
type: text
pattern: "^[a-z][a-z0-9_]*$"
outputs:
appName: "{{ inputs.appName }}"
projectName: "{{ inputs.projectName }}"# In workflow.yaml — local reference
- id: naming
uses: ./actions/app-naming.yaml
# Or shared from GitHub — same loading path as any action
- id: naming
uses: @github/my-org/xo-prompts/app-naming
# Access outputs in later steps
- run: echo "Creating {{ steps.naming.outputs.appName }}"Published action (GitHub)
An action in its own GitHub repo, shareable across generators. The repo must have an action.yaml at the root (or subpath). For private repos, set XO_GITHUB_TOKEN in your environment.
# @github/my-org/xo-ensure-ts — action.yaml
name: ensure-typescript
description: Installs TypeScript if missing
inputs:
strict:
default: "true"
steps:
- uses: xo/pkg-installed
id: hasTs
with:
pkg: typescript
- if: "steps.hasTs.outputs.installed == false"
uses: xo/install-pkg
with:
pkg: typescript
dev: true# Any version reference works
- uses: @github/my-org/xo-ensure-ts
- uses: @github/my-org/xo-ensure-ts@v1.2.0
- uses: @github/my-org/xo-actions/ensure-ts@v1.0.0| You want to… | Use |
|---|---|
| Reuse a group of xo/* steps within one generator | Composite (./actions/name.yaml) |
| Run custom logic, call APIs, or do anything YAML can't express | Script (./actions/name.js) |
| Collect a reusable set of inputs and expose as outputs | Prompt action (runs.using: prompt) |
| Share an action across many generator repos | Published (@github/owner/repo) |
Idempotency
Most built-in actions are safe to re-run — they check before writing and skip if the result is already in place.
| Action | Guarantee |
|---|---|
| xo/copy | Overwrites destination — caller should use if to guard |
| xo/template | Overwrites destination with rendered output |
| xo/append | Checks for duplicate content before appending |
| xo/inject | No-ops if marker content is already present |
| xo/replace | No-ops if find string is not found |
| xo/json | Merges; does not overwrite existing keys unless value differs |
| xo/env | Skips keys that already exist in the file |
| xo/ast-import | No-ops if import already present |
| xo/install-pkg | Skips if package already in dependencies |
| xo/run / run: | Caller's responsibility — use if conditions to guard |
| xo/prompt | Side-effect free for info/warn/success; confirm is interactive |
| Detection actions | Read-only — always safe to re-run |