Writing Custom Steps
Custom steps are written in TypeScript using the DevRamps SDK (@devramps/sdk-typescript). The SDK provides base classes, decorators, and utilities for building steps that integrate with the DevRamps platform.
Installation
npm install @devramps/sdk-typescript
Requirements:
- Node.js >= 18.0.0
- TypeScript >= 5.0.0
@devramps/sdk-typescript— Pin the version in yourpackage.jsonto avoid unexpected breaking changes (e.g.,"@devramps/sdk-typescript": "^1.0.0"). Check the changelog for updates.
Enable decorators in your tsconfig.json:
{
"compilerOptions": {
"experimentalDecorators": true,
"emitDecoratorMetadata": true
}
}
Simple Steps
A simple step runs once and returns a result. Extend SimpleStep and implement the run method.
import { SimpleStep, Step, StepOutputs, StepRegistry } from "@devramps/sdk-typescript";
import { z } from "zod";
const DeploySchema = z.object({
target: z.string(),
version: z.string(),
});
type DeployParams = z.infer<typeof DeploySchema>;
@Step({ name: "Deploy", type: "deploy", schema: DeploySchema })
class DeployStep extends SimpleStep<DeployParams> {
async run(params: DeployParams) {
this.logger.info("Starting deployment", { target: params.target });
// Your deployment logic here
const deploymentId = `deploy-${params.target}-${params.version}`;
this.logger.info("Deployment complete", { deploymentId });
return StepOutputs.success({ deploymentId });
}
}
// Register and run
StepRegistry.run([DeployStep]);
Key points
@Stepdecorator: Registers metadata --name(display name),type(unique identifier), andschema(Zod validation schema for parameters).runmethod: Contains your step logic. Receives validated parameters.this.logger: Structured logger that streams output to the DevRamps dashboard.StepOutputs.success(data): Returns success with optional output data.StepOutputs.failed(message): Returns failure with an error message.StepRegistry.run(steps): Entry point that handles CLI argument parsing and step execution.
Polling Steps
Polling steps are for long-running operations. They separate the initial trigger from periodic status checks.
Extend PollingStep and implement trigger and poll methods.
import { PollingStep, Step, StepOutputs, StepRegistry } from "@devramps/sdk-typescript";
import { z } from "zod";
const BuildSchema = z.object({
project: z.string(),
branch: z.string(),
});
type BuildParams = z.infer<typeof BuildSchema>;
type BuildPollingState = {
buildId: string;
startedAt: number;
};
@Step({ name: "Build", type: "build", schema: BuildSchema })
class BuildStep extends PollingStep<BuildParams, BuildPollingState> {
async trigger(params: BuildParams) {
this.logger.info("Starting build", { project: params.project });
const buildId = await startBuild(params.project, params.branch);
// Return TRIGGERED with polling state
return StepOutputs.triggered({
buildId,
startedAt: Date.now(),
});
}
async poll(params: BuildParams, state: BuildPollingState) {
const status = await checkBuildStatus(state.buildId);
if (status === "running") {
// Still running -- poll again in 5 seconds
return StepOutputs.pollAgain(state, 5000);
}
if (status === "failed") {
return StepOutputs.failed("Build failed", "BUILD_FAILED");
}
return StepOutputs.success({ buildId: state.buildId, status: "complete" });
}
}
StepRegistry.run([BuildStep]);
How polling works
triggerruns once to start the operation. ReturnsStepOutputs.triggered(state)with initial polling state.pollruns repeatedly with the current polling state. Returns one of:StepOutputs.pollAgain(state, delayMs)-- Keep polling. The delay is optional (default: varies).StepOutputs.success(data)-- The operation completed successfully.StepOutputs.failed(message)-- The operation failed.
The polling state is serialized between calls, so include any data your poll method needs to check status.
Approval Workflows
Steps can require manual approval before executing. Override the prepare method to request approval.
Simple step with approval
@Step({ name: "Delete User", type: "delete-user", schema: DeleteUserSchema })
class DeleteUserStep extends SimpleStep<DeleteUserParams> {
async prepare(params: DeleteUserParams) {
return StepOutputs.approvalRequired({
context: `Delete user ${params.userId}? Reason: ${params.reason}`,
});
}
async run(params: DeleteUserParams, approval?: ApprovalContext) {
this.logger.info("Deleting user", {
userId: params.userId,
approvedBy: approval?.approverId,
});
await deleteUser(params.userId);
return StepOutputs.success({ deleted: true });
}
}
Polling step with approval
@Step({ name: "Production Deploy", type: "production-deploy", schema: DeploySchema })
class ProductionDeployStep extends PollingStep<DeployParams, DeployState> {
async prepare(params: DeployParams) {
return StepOutputs.approvalRequired({
context: `Deploy ${params.service} v${params.version} to production?`,
});
}
async trigger(params: DeployParams, approval?: ApprovalContext) {
this.logger.info("Deploying", { approvedBy: approval?.approverId });
const deploymentId = await startDeploy(params);
return StepOutputs.triggered({ deploymentId });
}
async poll(_params: DeployParams, state: DeployState) {
const status = await getDeploymentStatus(state.deploymentId);
if (status === "in_progress") return StepOutputs.pollAgain(state, 10000);
if (status === "failed") return StepOutputs.failed("Deployment failed");
return StepOutputs.success({ deploymentId: state.deploymentId });
}
}
Approval flow
- DevRamps calls
prepare. If it returnsAPPROVAL_REQUIRED, the step pauses. - A user reviews the approval context in the dashboard and approves or rejects.
- If approved,
run(simple) ortrigger(polling) is called with theApprovalContext. - If rejected, the step fails.
Logging
Steps have access to a structured logger via this.logger:
this.logger.info("Processing started", { itemCount: 10 });
this.logger.error("Failed to connect", { host: "example.com" });
Logs are written as JSON Lines and streamed to the DevRamps dashboard in real time.
Step Output Reference
| Method | Used In | Description |
|---|---|---|
StepOutputs.success(data?) | run, poll | Step completed successfully. Optional data is passed as step outputs. |
StepOutputs.failed(message, code?) | run, trigger, poll, prepare | Step failed with an error message and optional error code. |
StepOutputs.triggered(state) | trigger | Long-running operation started. State is passed to poll. |
StepOutputs.pollAgain(state, delayMs?) | poll | Still running. Poll again after the specified delay. |
StepOutputs.approvalRequired(opts?) | prepare | Request manual approval. opts.context describes what's being approved. |
Complete Example
import {
SimpleStep,
PollingStep,
Step,
StepOutputs,
StepRegistry,
ApprovalContext,
} from "@devramps/sdk-typescript";
import { z } from "zod";
// Simple notification step
const NotifySchema = z.object({ channel: z.string(), message: z.string() });
@Step({ name: "Send Notification", type: "notify", schema: NotifySchema })
class NotifyStep extends SimpleStep<z.infer<typeof NotifySchema>> {
async run(params: z.infer<typeof NotifySchema>) {
this.logger.info("Sending notification", { channel: params.channel });
// Send notification...
return StepOutputs.success({ sent: true });
}
}
// Database migration with approval and polling
const MigrationSchema = z.object({ database: z.string(), version: z.string() });
type MigrationState = { migrationId: string; startedAt: number };
@Step({ name: "Database Migration", type: "db-migration", schema: MigrationSchema })
class MigrationStep extends PollingStep<z.infer<typeof MigrationSchema>, MigrationState> {
async prepare(params: z.infer<typeof MigrationSchema>) {
return StepOutputs.approvalRequired({
context: `Run migration ${params.version} on ${params.database}?`,
});
}
async trigger(params: z.infer<typeof MigrationSchema>, approval?: ApprovalContext) {
this.logger.info("Starting migration", { approvedBy: approval?.approverId });
return StepOutputs.triggered({ migrationId: `mig-${Date.now()}`, startedAt: Date.now() });
}
async poll(_params: z.infer<typeof MigrationSchema>, state: MigrationState) {
const elapsed = Date.now() - state.startedAt;
if (elapsed < 30000) return StepOutputs.pollAgain(state, 5000);
return StepOutputs.success({ migrationId: state.migrationId, duration: elapsed });
}
}
StepRegistry.run([NotifyStep, MigrationStep]);
Testing Custom Steps Locally
Custom steps can be tested locally by calling your step class methods directly in a test file. Since steps are plain TypeScript classes, you can:
- Unit test individual methods — Call
execute(),trigger(), orpoll()directly with mock parameters. - Test the full lifecycle — Create an instance of your step class and call the methods in sequence.
- Mock external services — Use your preferred mocking library (Jest, Vitest) to mock AWS SDK calls or HTTP requests.
// Example: Testing a simple step
import { NotifyStep } from "./steps";
describe("NotifyStep", () => {
it("should return success", async () => {
const step = new NotifyStep();
const result = await step.execute({ channel: "#test", message: "hello" });
expect(result.status).toBe("success");
});
});
There is no local DevRamps emulator — test your step logic in isolation, then push to a step registry to test the full integration with DevRamps.
Next Steps
- Step Registries -- Package and deploy your custom steps.
- Using Custom Steps -- Reference custom steps in your pipeline YAML.