Creating a Custom Deployment Step

Creating a Custom Deployment Step

Published May 19, 2025 Last updated April 12, 2026
tutorial pipelines ci-cd devops

Creating a Custom Deployment Step

Dev Ramps has built-in steps for ECS, EKS, Lambda, Terraform, and a few other AWS services. But your deployment pipeline probably does things those steps don’t cover: invalidating a CDN, running a database migration against a custom ORM, notifying an internal tool, or calling a third-party API. Custom steps let you write TypeScript that runs as a first-class pipeline step, with the same logging, parameter validation, and output system as built-in steps.

This tutorial walks through creating two custom steps: a simple one-shot step and a polling step for long-running operations.

Setting Up a Step Registry

A step registry is a GitHub repository containing your custom step code. Dev Ramps clones it, builds it, and extracts step metadata on every push. The structure is minimal:

my-step-registry/
├── src/
│   └── steps.ts
├── package.json
└── tsconfig.json

Your package.json needs two scripts that Dev Ramps calls during the build:

{
  "name": "my-step-registry",
  "scripts": {
    "build-step-registry": "tsc",
    "start-step-registry": "node dist/steps.js"
  },
  "dependencies": {
    "@devramps/sdk-typescript": "^1.0.0",
    "zod": "^3.0.0"
  },
  "devDependencies": {
    "typescript": "^5.0.0"
  }
}

And your tsconfig.json must enable decorators:

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "commonjs",
    "outDir": "dist",
    "experimentalDecorators": true,
    "emitDecoratorMetadata": true
  },
  "include": ["src"]
}

To register the repository in Dev Ramps, go to Organization Settings > Step Registries > Create Step Registry. Point it at your GitHub repo, the branch to build from, and the path to package.json.

Every push to the configured branch triggers a rebuild. If the build fails, your previously registered steps keep working.

Writing a Simple Step

A simple step runs once and returns a result. Here’s a step that invalidates a CloudFront distribution after a deploy:

import { SimpleStep, Step, StepOutputs, StepRegistry } from "@devramps/sdk-typescript";
import { z } from "zod";
import { CloudFrontClient, CreateInvalidationCommand } from "@aws-sdk/client-cloudfront";

const InvalidateSchema = z.object({
  distributionId: z.string(),
  paths: z.array(z.string()),
});

type InvalidateParams = z.infer<typeof InvalidateSchema>;

@Step({ name: "Invalidate CDN", type: "cdn-invalidate", schema: InvalidateSchema })
class InvalidateCDNStep extends SimpleStep<InvalidateParams> {
  async run(params: InvalidateParams) {
    const client = new CloudFrontClient({});

    this.logger.info("Invalidating CloudFront distribution", {
      distributionId: params.distributionId,
      pathCount: params.paths.length,
    });

    const result = await client.send(new CreateInvalidationCommand({
      DistributionId: params.distributionId,
      InvalidationBatch: {
        CallerReference: `deploy-${Date.now()}`,
        Paths: { Quantity: params.paths.length, Items: params.paths },
      },
    }));

    this.logger.info("Invalidation created", {
      invalidationId: result.Invalidation?.Id,
    });

    return StepOutputs.success({
      invalidationId: result.Invalidation?.Id,
    });
  }
}

StepRegistry.run([InvalidateCDNStep]);

Three things to note:

  1. The @Step decorator registers the step’s name, type (used in pipeline YAML), and a Zod schema for parameter validation. Parameters are validated before your code runs, so you don’t need to check them yourself.

  2. this.logger sends structured logs to the Dev Ramps dashboard in real time. Use it instead of console.log.

  3. StepOutputs.success(data) returns output data that subsequent steps can reference via expressions like ${{ steps.cdn.invalidationId }}.

If something goes wrong, return StepOutputs.failed("message") instead. The step will be marked as failed and the pipeline will stop (or trigger a rollback, depending on your stage configuration).

Writing a Polling Step

Some operations take minutes to complete. A database migration, a smoke test suite, or a blue/green swap might need 30 seconds to 10 minutes. Polling steps handle this by separating the “start the operation” logic from the “check if it’s done” logic.

Here’s a step that runs a database migration and polls for completion:

import { PollingStep, Step, StepOutputs, StepRegistry } from "@devramps/sdk-typescript";
import { z } from "zod";

const MigrationSchema = z.object({
  database: z.string(),
  version: z.string(),
});

type MigrationParams = z.infer<typeof MigrationSchema>;
type MigrationState = { migrationId: string; startedAt: number };

@Step({ name: "Database Migration", type: "db-migrate", schema: MigrationSchema })
class MigrationStep extends PollingStep<MigrationParams, MigrationState> {
  async trigger(params: MigrationParams) {
    this.logger.info("Starting migration", {
      database: params.database,
      version: params.version,
    });

    const migrationId = await startMigration(params.database, params.version);

    return StepOutputs.triggered({
      migrationId,
      startedAt: Date.now(),
    });
  }

  async poll(params: MigrationParams, state: MigrationState) {
    const status = await checkMigrationStatus(state.migrationId);

    if (status === "running") {
      this.logger.info("Migration still running", {
        elapsed: Math.round((Date.now() - state.startedAt) / 1000) + "s",
      });
      return StepOutputs.pollAgain(state, 5000); // check again in 5 seconds
    }

    if (status === "failed") {
      return StepOutputs.failed("Migration failed");
    }

    return StepOutputs.success({
      migrationId: state.migrationId,
      duration: Date.now() - state.startedAt,
    });
  }
}

StepRegistry.run([MigrationStep]);

The key difference from a simple step: trigger() runs once to kick off the operation and returns a state object. Dev Ramps then calls poll() repeatedly with that state until you return success() or failed(). The second argument to pollAgain() controls the delay between polls in milliseconds.

The state object is serialized between calls, so include everything poll() needs to check on the operation. Don’t rely on instance variables since each poll may run in a fresh context.

Using Custom Steps in Your Pipeline

Once your step registry builds successfully, reference custom steps in pipeline.yaml with the CUSTOM: prefix:

steps:
  - name: Deploy to ECS
    type: DEVRAMPS:ECS:DEPLOY
    params:
      cluster_name: my-cluster
      service_name: api

  - name: Invalidate CDN Cache
    type: CUSTOM:CDN-INVALIDATE
    id: cdn
    goes_after: ["Deploy to ECS"]
    params:
      distributionId: "E1ABCDEF12345"
      paths:
        - "/*"

  - name: Run Migrations
    type: CUSTOM:DB-MIGRATE
    goes_after: ["Deploy to ECS"]
    params:
      database: ${{ vars.database_url }}
      version: ${{ trigger.sha }}

The type after CUSTOM: matches the type field in your @Step decorator, uppercased. Parameters support the same expression syntax as built-in steps, so you can reference stage variables, secrets, artifact URLs, and outputs from previous steps.

Approval Workflows

Custom steps also support approval gates. Add a prepare() method to either a simple or polling step and return StepOutputs.approvalRequired(). Dev Ramps pauses the step, shows the approval request in the dashboard (and Slack, if configured), and waits for someone to approve or reject before running the step. See the custom steps documentation for the full API.

Multiple Steps in One Registry

You can register multiple steps from a single repository. Pass them all to StepRegistry.run():

StepRegistry.run([InvalidateCDNStep, MigrationStep, SmokeTestStep]);

Each step gets its own CUSTOM:* type in your pipeline configuration. Group steps that share dependencies or are maintained by the same team.

Conclusion

Custom steps fill the gap between what Dev Ramps supports out of the box and what your deployment actually needs. Start with a simple step for your most common manual post-deploy task (CDN invalidation, cache warming, Slack notification), then add polling steps for anything that takes more than a few seconds. The custom steps docs cover the full SDK API, including error handling, approval workflows, and testing patterns.