Skip to content

Backend Plugin Development Guide

Backend plugins provide type-safe RPC APIs, business logic, database schemas, and integration with external services. They are built using oRPC for contract implementation, Drizzle for database access, and Zod for validation.

The backend implements contracts defined in -common packages, ensuring end-to-end type safety from database to frontend.

The fastest way to create a backend plugin is using the CLI scaffolding tool:

Terminal window
bun run create

Interactive prompts:

  1. Select backend as the plugin type
  2. Enter your plugin name (e.g., myfeature)
  3. Provide a description (optional)
  4. Confirm to generate

This will create a complete plugin structure with:

  • ✅ Package configuration with all required dependencies
  • ✅ TypeScript configuration
  • ✅ Drizzle database schema template
  • ✅ oRPC router with access middleware
  • ✅ Service layer with CRUD operations
  • ✅ Plugin registration
  • ✅ Initial changeset for version management

Generated structure:

plugins/myfeature-backend/
├── .changeset/
│ └── initial.md # Version changeset
├── drizzle.config.ts # Drizzle Kit configuration
├── package.json # Dependencies
├── tsconfig.json # TypeScript config
├── README.md # Documentation
└── src/
├── index.ts # Plugin entry point
├── router.ts # oRPC router implementation
├── service.ts # Business logic layer
└── schema.ts # Drizzle database schema
Terminal window
cd plugins/myfeature-backend
bun install

The generated plugin is a fully functional example. Customize it for your domain:

src/schema.ts:

import { pgTable, text, timestamp, uuid } from "drizzle-orm/pg-core";
export const myItems = pgTable("items", {
id: uuid("id").primaryKey().defaultRandom(),
name: text("name").notNull(),
description: text("description"),
createdAt: timestamp("created_at").notNull().defaultNow(),
updatedAt: timestamp("updated_at").notNull().defaultNow(),
});

After modifying the schema:

Terminal window
bun run drizzle-kit generate

This creates a migration in the migrations/ directory.

The generated service layer provides a starting point. Extend it with your domain logic:

src/service.ts:

export class MyFeatureService {
constructor(private readonly database: Database) {}
async getItems() {
return await this.database.select().from(myItems);
}
async createItem(data: CreateMyItem) {
// Add custom validation or business logic
const [item] = await this.database
.insert(myItems)
.values({
name: data.name,
description: data.description ?? null,
})
.returning();
return item;
}
// Add your custom methods here
}

The router implements the contract from your common package using the contract-based approach:

src/router.ts:

import { implement } from "@orpc/server";
import { autoAuthMiddleware, type RpcContext } from "@checkstack/backend-api";
import { myFeatureContract } from "@checkstack/myfeature-common";
/**
* Create the router using contract-based implementation.
* Auth and access rules are automatically enforced via autoAuthMiddleware
* based on the contract's meta.userType and meta.access.
*/
const os = implement(myFeatureContract)
.$context<RpcContext>()
.use(autoAuthMiddleware);
export function createMyFeatureRouter({ database }: { database: Database }) {
const service = new MyFeatureService(database);
return os.router({
// Handler names must match the contract procedure names
getItems: os.getItems.handler(async () => {
return await service.getItems();
}),
createItem: os.createItem.handler(async ({ input }) => {
return await service.createItem(input);
}),
// Add more handlers matching your contract
});
}
Terminal window
# Type check
bun run typecheck
# Lint
bun run lint
# Run tests
bun test

That’s it! Your backend plugin is ready to use.

Note: Don’t forget to also create the corresponding -common package to define your contract. See Common Plugin Guidelines for details.

Creates a backend plugin with the specified configuration.

Parameters:

  • metadata (PluginMetadata): Plugin metadata object containing pluginId
  • register (function): Registration function called by the core

The register function receives an environment object with these methods:

env.registerAccessRules(accessRules: AccessRule[])

Section titled “env.registerAccessRules(accessRules: AccessRule[])”

Register access rules that this plugin provides.

env.registerAccessRules([
{ id: "item.read", description: "Read items", isDefault: true },
{ id: "item.manage", description: "Manage items" },
]);

Note: The core automatically prefixes access rule IDs with the plugin ID. item.read becomes myplugin.item.read

FieldTypeDescription
idstringUnique access rule identifier (auto-prefixed with plugin ID)
descriptionstring?Human-readable description
isDefaultboolean?If true, access rule is auto-assigned to the “users” role
Default Access Rules and the “Users” Role
Section titled “Default Access Rules and the “Users” Role”

The platform has a built-in “users” system role that is automatically assigned to newly registered users. Access rules marked with isDefault: true are automatically synced to this role during backend startup.

How it works:

  1. On startup, the auth-backend collects all access rules from all plugins
  2. Access rules with isDefault: true are synced to the “users” role
  3. Administrators can still manually remove default access rules from the “users” role
  4. Removed defaults are tracked in the disabled_default_access_rule table
  5. Re-adding a default access rule via the admin UI clears the disabled flag

Example:

// In your access rules definition
export const access = {
// This access rule will be auto-assigned to all new users
itemRead: {
id: "item.read",
description: "Read items",
isDefault: true // ✅ Granted to "users" role
},
// This access rule requires manual role assignment
itemManage: {
id: "item.manage",
description: "Manage items"
// isDefault: false by default
},
} as const satisfies Record<string, AccessRule>;

System Roles:

RoleTypeDescription
adminSystemWildcard access to all access rules. Cannot delete. Access rules not editable.
usersSystemAuto-assigned to new users. Default access rules synced here. Cannot delete.

See also: For resource-level access control (restricting access to specific systems, health checks, etc.), see Teams and Resource-Level Access Control.

Register the plugin’s initialization function.

Config:

  • schema: Drizzle schema object (optional)
  • deps: Dependencies to inject
  • init: Async initialization function (Phase 2)
  • afterPluginsReady: Async function called after all plugins initialized (Phase 3, optional)
import { OtherApi } from "@checkstack/other-common";
env.registerInit({
schema: mySchema,
deps: {
rpc: coreServices.rpc,
logger: coreServices.logger,
rpcClient: coreServices.rpcClient,
},
// Phase 2: Register routers and services
// DO NOT make RPC calls to other plugins here
init: async ({ database, rpc, logger }) => {
const router = createMyRouter(database);
rpc.registerRouter(router); // No plugin ID needed - auto-detected from metadata
},
// Phase 3: Called after ALL plugins are initialized
// Safe to make RPC calls and subscribe to hooks
afterPluginsReady: async ({ database, rpcClient, onHook, emitHook }) => {
// Call other plugins via RPC using their Api definition
const otherClient = rpcClient.forPlugin(OtherApi);
await otherClient.someMethod({ ... });
// Subscribe to hooks
onHook(coreHooks.someEvent, async (payload) => {
// Handle event
});
},
});

Plugins that depend on external connectivity (queues, caches, external APIs) should contribute a probe to the /ready endpoint via coreServices.readinessRegistry. The platform’s /ready probe will not return 200 while any critical plugin probe is failing — this gates orchestrators (k8s/docker-compose) from sending traffic to a backend that isn’t actually ready. See Health & Readiness for the full API and probe contract.

The platform provides a distributed hook/event system for cross-plugin communication. Hooks are delivered via the queue system for reliable multi-instance support.

ModeDescriptionUse Case
broadcast (default)All instances receive and processUI updates, config changes
work-queueOnly one instance processes (load-balanced)DB writes, external API calls
instance-localIn-memory only, not distributedCleanup hooks, shutdown
afterPluginsReady: async ({ onHook }) => {
// Broadcast mode (default) - all instances receive
onHook(coreHooks.configUpdated, async ({ pluginId, key, value }) => {
// Handle config change
});
// Work-queue mode - only one instance handles
onHook(
coreHooks.accessRulesRegistered,
async ({ pluginId, accessRules }) => {
// Sync to database
},
{
mode: "work-queue",
workerGroup: "access-rule-sync", // Namespaced automatically
maxRetries: 5,
}
);
// Instance-local mode - not distributed
onHook(
coreHooks.pluginDeregistering,
async ({ pluginId, reason }) => {
// Cleanup local resources
},
{ mode: "instance-local" }
);
}
afterPluginsReady: async ({ emitHook }) => {
// Regular emit (distributed via queue)
await emitHook(coreHooks.configUpdated, {
pluginId: "my-plugin",
key: "apiKey",
value: "new-value",
});
}
HookPayloadDescription
accessRulesRegistered{ pluginId, accessRules }Plugin registered access rules
configUpdated{ pluginId, key, value }Configuration changed
pluginInitialized{ pluginId }Plugin completed init (Phase 2)
Installation
pluginInstallationRequested{ pluginId, pluginPath }Installation broadcast
pluginInstalling{ pluginId }LOCAL: Loading on THIS instance
pluginInstalled{ pluginId }Plugin fully loaded
Deregistration
pluginDeregistrationRequested{ pluginId, deleteSchema }Deregistration broadcast
pluginDeregistering{ pluginId, reason }LOCAL: Cleanup on THIS instance
pluginDeregistered{ pluginId }Plugin fully removed
Lifecycle
platformShutdown{ reason }Platform shutting down

When a plugin is installed at runtime in a multi-instance setup:

sequenceDiagram
participant API as Instance A (API)
participant FS as Shared Filesystem
participant Q as Queue (Broadcast)
participant B as Instance B
API->>FS: 1. npm install plugin
API->>API: 2. Set DB enabled=true
API->>Q: 3. Emit pluginInstallationRequested (broadcast)
Q-->>API: 4a. Receives broadcast
Q-->>B: 4b. Receives broadcast
par Each instance locally
API->>API: 5a. emitLocal pluginInstalling
API->>API: 6a. Load plugin into memory
B->>B: 5b. emitLocal pluginInstalling
B->>B: 6b. Load plugin into memory
end
API->>Q: 7. Emit pluginInstalled (work-queue)
Note over Q: Only ONE instance handles DB sync

When a plugin is deregistered in a multi-instance setup:

sequenceDiagram
participant API as Instance A (API)
participant Q as Queue (Broadcast)
participant B as Instance B
API->>API: 1. Set DB enabled=false
API->>Q: 2. Emit pluginDeregistrationRequested (broadcast)
Q-->>API: 3a. Receives broadcast
Q-->>B: 3b. Receives broadcast
par Each instance locally
API->>API: 4a. emitLocal pluginDeregistering
API->>API: 5a. Run cleanup handlers
B->>B: 4b. emitLocal pluginDeregistering
B->>B: 5b. Run cleanup handlers
end
API->>Q: 6. Emit pluginDeregistered (work-queue)
Note over Q: Only ONE instance handles DB cleanup

Plugins can register cleanup logic that runs when deregistered:

register: (env) => {
// Register cleanup handler (runs LIFO on deregistration)
env.registerCleanup(async () => {
// Cancel recurring jobs, close connections, etc.
await myQueue.cancelRecurring("my-job");
});
}

env.registerService<S>(ref: ServiceRef<S>, impl: S)

Section titled “env.registerService<S>(ref: ServiceRef<S>, impl: S)”

Register a service that other plugins can use.

const myServiceRef = createServiceRef<MyService>("my-service");
env.registerService(myServiceRef, {
doSomething: async () => {
// Implementation
},
});

env.registerExtensionPoint<T>(ref: ExtensionPoint<T>, impl: T)

Section titled “env.registerExtensionPoint<T>(ref: ExtensionPoint<T>, impl: T)”

Register an implementation for an extension point.

import { healthCheckExtensionPoint } from "@checkstack/backend-api";
env.registerExtensionPoint(healthCheckExtensionPoint, {
id: "http-check",
displayName: "HTTP Health Check",
execute: async (config) => {
// Implementation
},
});

The core provides these services via coreServices:

The RPC service for registering oRPC routers.

Routers are automatically mounted at: /api/<pluginId>/

const router = createMyPluginRouter(database);
rpc.registerRouter(router); // Plugin ID auto-detected from metadata
// Procedures accessible at: /api/myplugin/<procedureName>

Critical: The registration name must match the plugin ID exactly for frontend clients to work correctly.

Structured logging service.

logger.info("Informational message");
logger.warn("Warning message");
logger.error("Error message");
logger.debug("Debug message");

Contracts are defined in the -common package using @orpc/contract. See Common Plugin Guidelines for details.

The backend router implements the contract using the contract-based approach with implement() and autoAuthMiddleware:

import { implement } from "@orpc/server";
import { autoAuthMiddleware, type RpcContext } from "@checkstack/backend-api";
import { myPluginContract, accessRuleList } from "@checkstack/myplugin-common";
/**
* Creates the router using contract-based implementation.
* Auth and access rules are automatically enforced via autoAuthMiddleware
* based on the contract's meta.userType and meta.access.
*/
const os = implement(myPluginContract)
.$context<RpcContext>()
.use(autoAuthMiddleware);
export const createMyPluginRouter = (database: Database) => {
return os.router({
// Handler names must match contract procedure names
getItems: os.getItems.handler(async () => {
// Auth and access rules auto-enforced from contract meta
// Implementation
}),
});
};

The project uses contract-driven security enforcement:

  • Contracts declare auth requirements via .meta({ userType: "user", access: [...] })
  • autoAuthMiddleware automatically enforces these requirements at runtime

This pattern ensures security is:

  • Self-documenting: Requirements visible in the contract
  • Automatically enforced: No manual middleware chaining needed
  • Type-safe: Contract meta determines context.user type
// In contract (declaration AND enforcement):
import type { ProcedureMetadata } from "@checkstack/common";
const _base = oc.$meta<ProcedureMetadata>({});
export const myPluginContract = {
// Requires authenticated user with specific access
getItems: _base
.meta({ userType: "user", access: [access.itemRead.id] })
.output(z.array(ItemSchema)),
// Public endpoint (no auth required)
getPublicInfo: _base
.meta({ userType: "anonymous" })
.output(z.object({ version: z.string() })),
// Service-to-service endpoint
internalSync: _base
.meta({ userType: "service" })
.output(z.void()),
};
ValueDescription
"anonymous"No authentication required (public endpoints)
"user"Only real users (frontend authenticated)
"service"Only services (backend-to-backend)
"authenticated"Either users or services, but must be authenticated (default)

oRPC automatically infers types from the procedure chain. Do not add explicit type annotations to handler parameters.

// ✅ Good - Let oRPC infer types
.handler(async ({ input, context }) => {
// input and context are automatically typed
})
// ❌ Bad - Don't add complex type annotations
.handler(async ({ input, context }: { input: SomeType; context: SomeContext }) => {
// This breaks inference
})

When you have both single-resource and bulk endpoints that share the same permission but need different access control configurations, use the instanceAccess override at the contract level.

Why? Access rules can have an instanceAccess config (like idParam for single resources or recordKey for bulk). Instead of creating duplicate access rules, you can override this at the contract level.

Example:

import { proc, accessPair } from "@checkstack/common";
// Define access rule with idParam (for single resource endpoints)
export const incidentAccess = {
incident: accessPair(
"incident",
{
read: {
description: "View incidents",
isDefault: true,
isPublic: true,
},
manage: {
description: "Manage incidents",
},
},
{
idParam: "systemId", // Single resource check
}
),
};
export const incidentContract = {
// Single endpoint - uses access rule's idParam
getIncidentsForSystem: proc({
operationType: "query",
userType: "public",
access: [incidentAccess.incident.read],
})
.input(z.object({ systemId: z.string() }))
.output(z.array(IncidentSchema)),
// Bulk endpoint - overrides to use recordKey AND switches to POST so the
// potentially-large systemIds array doesn't blow past URL-length limits.
getBulkIncidentsForSystems: proc({
operationType: "query",
userType: "public",
access: [incidentAccess.incident.read], // Same access rule
instanceAccess: { recordKey: "incidents" }, // Override for bulk
})
.route({ method: "POST" }) // ← override default GET
.input(z.object({ systemIds: z.array(z.string()) }))
.output(z.object({ incidents: z.record(z.string(), z.array(IncidentSchema)) })),
};

Procedures are exposed both at /api/{pluginId}/ (oRPC’s native wire format) and at /rest/{pluginId}/{procedure} (REST/OpenAPI, suitable for external clients). The HTTP method on the REST mount is derived from operationType by the proc() builder:

operationTypeDefault method
"query"GET
"mutation"POST

For idiomatic REST semantics, override the method by chaining .route({ method: "..." }) on mutations after the proc({...}) call:

Procedure name patternConvention
update* mutation.route({ method: "PATCH" })
delete* / remove* mutation.route({ method: "DELETE" })
getBulk* query.route({ method: "POST" }) (see below)
Any query taking a large array input.route({ method: "POST" })

@orpc/openapi@1.13.x has no automatic GET→POST fallback when the URL would exceed length limits. Bracket-notation encoding of a string[] of IDs blows past the typical 8 KB cap quickly, so any query that takes a potentially-large array input must opt out of the GET default.

When a procedure is GET, the top-level input schema must be an object (or any / unknown). A bare scalar like .input(z.string()) will cause the OpenAPI generator to throw at boot:

[OpenAPIGenerator] Error occurred while generating OpenAPI for procedure
at path: <plugin>.<procedure>
When method is "GET", input schema must satisfy: object | any | unknown

This isn’t a serializer limitation — it’s that a query string needs a field name to attach each value to, and a top-level scalar has no key. Fix it by wrapping the input:

// ❌ Won't generate as GET
.input(z.string())
// ✅ Idiomatic
.input(z.object({ id: z.string() }))

Nested objects, arrays, and z.date() inside the object input are all fine — they’re serialized as bracket-notation params (?filter[status]=active&ids[0]=a) and z.date() becomes an ISO 8601 string.

Benefits:

  • ✅ Single access rule for both single and bulk endpoints
  • ✅ No duplicate access rules in the UI
  • ✅ Same permission governs both endpoint types
  • ✅ Clear separation between authorization (access rule) and filtering strategy (instanceAccess)

instanceAccess Options:

FieldTypeUse Case
idParamstringSingle resource: Check access to specific resource ID from input
listKeystringList filtering: Filter output array by accessible resources
recordKeystringRecord filtering: Filter output record by accessible resource keys

Important: Distinguish between plugin configuration and user data:

  • Plugin Configuration: Use ConfigService for settings that control plugin behavior

  • User Data: Use custom Drizzle schemas for user-created content

  • Examples: Health check instances, catalog systems, user-created items

  • Define schema in src/schema.ts as shown below

When in doubt: Ask “Is this controlling the plugin’s behavior, or is it content users create?”
Behavior → ConfigService | Content → Custom schema

src/schema.ts:

import { pgTable, text, timestamp } from "drizzle-orm/pg-core";
export const items = pgTable("items", {
id: text("id").primaryKey(),
name: text("name").notNull(),
description: text("description"),
createdAt: timestamp("created_at").defaultNow().notNull(),
updatedAt: timestamp("updated_at").defaultNow().notNull(),
});

Backend plugins should extend the shared backend configuration.

tsconfig.json:

{
"extends": "@checkstack/tsconfig/backend.json",
"include": ["src"]
}

See Monorepo Tooling for more information.

drizzle.config.ts:

import { defineConfig } from "drizzle-kit";
export default defineConfig({
schema: "./src/schema.ts",
dialect: "postgresql",
out: "./drizzle",
});
Terminal window
bun run drizzle-kit generate

This creates migration files in ./drizzle/.

The core automatically runs migrations when the plugin loads. No manual migration step needed!

See Drizzle Schema Isolation for details.

Routers should be created as factory functions that accept the plugin-scoped database instance:

import type { SafeDatabase } from "@checkstack/backend-api";
export const createMyPluginRouter = (
database: SafeDatabase<typeof schema>
) => {
return os.router({
// Procedures use the captured database, NOT context.db
});
};

Why? The context.db in oRPC handlers is the admin database pool. Plugin tables are isolated in schemas like plugin_<id>, so using context.db will result in “relation does not exist” errors.

Solution: Capture the plugin-scoped database via the factory pattern and use it in all handlers.

env.registerInit({
deps: {
rpc: coreServices.rpc,
logger: coreServices.logger,
myService: myServiceRef,
},
init: async ({ database, rpc, logger, myService }) => {
// All dependencies are resolved and typed
},
});

Dependencies are fully typed. TypeScript will error if:

  • You declare a dependency that doesn’t exist
  • You use a dependency with the wrong type
  • You forget to declare a dependency you use

src/router.test.ts:

import { describe, it, expect, mock } from "bun:test";
import { createMyPluginRouter } from "./router";
import { createMockRpcContext } from "@checkstack/backend-api";
import { call } from "@orpc/server";
describe("MyPlugin Router", () => {
// 1. Create a mock database instance
const mockDb = {
select: mock().mockReturnValue({
from: mock().mockReturnValue([
{ id: "1", name: "Test Item", description: null },
]),
}),
} as any;
// 2. Initialize the router with the mock database
const router = createMyPluginRouter(mockDb);
it("getItems returns items", async () => {
const context = createMockRpcContext({
user: { id: "test-user", roles: ["admin"] },
});
// 3. Use 'call' from @orpc/server to execute the procedure
const result = await call(router.getItems, undefined, { context });
expect(Array.isArray(result)).toBe(true);
expect(mockDb.select).toHaveBeenCalled();
});
});

src/service.test.ts:

import { describe, expect, test, beforeEach } from "bun:test";
import { drizzle } from "drizzle-orm/node-postgres";
import { Pool } from "pg";
import * as schema from "./schema";
import { ItemService } from "./service";
describe("ItemService", () => {
let db: ReturnType<typeof drizzle>;
let service: ItemService;
beforeEach(async () => {
const pool = new Pool({
connectionString: process.env.TEST_DATABASE_URL,
});
db = drizzle(pool, { schema });
service = new ItemService(db);
// Clean up
await db.delete(schema.items);
});
test("creates item", async () => {
const item = await service.createItem({
name: "Test Item",
description: "Test Description",
});
expect(item.name).toBe("Test Item");
expect(item.id).toBeDefined();
});
});

Test plugin registration and initialization:

import { describe, expect, test } from "bun:test";
import plugin from "./index";
describe("MyPlugin Backend", () => {
test("exports plugin", () => {
expect(plugin.pluginId).toBe("myplugin");
expect(plugin.register).toBeFunction();
});
});

Don’t put business logic directly in procedure handlers:

// ❌ Bad
getItems: os.getItems.handler(async () => {
const items = await database.select().from(schema.items);
return items;
}),
// ✅ Good
getItems: os.getItems.handler(async () => {
return await itemService.getItems();
}),

Never require IDs from the frontend. Generate them in the service layer:

async createItem(data: NewItem) {
const [item] = await this.database
.insert(schema.items)
.values({
id: uuidv4(), // Generate ID internally
...data
})
.returning();
return item;
}

Drizzle’s json() columns infer to unknown. Use type assertions to bridge to your contract types:

.handler(async () => {
const result = await service.getItems();
return result as unknown as Array<typeof result[number] & {
metadata: Record<string, unknown> | null
}>;
});
logger.info("Item created", { itemId: item.id });
logger.warn("Item not found", { itemId: id });
logger.error("Failed to create item", { error: err.message });

Test all services and critical paths:

Terminal window
bun test

If your oRPC endpoints return 404:

  1. Verify router is registered with rpc.registerRouter
  2. Ensure registration name exactly matches the plugin ID
  3. Check plugin initialization is executed (check backend logs)
  4. Verify frontend client uses matching plugin ID

If your oRPC endpoints return 500 after routing is fixed:

  1. Missing Database Migrations: Check backend logs for “relation does not exist”
  2. Context Database: Ensure handlers use the captured plugin-scoped database, NOT context.db
  3. Validation Errors: Check that service layer returns data matching the contract output schema

If TypeScript complains about handler types:

  1. Remove explicit type annotations from handler parameters
  2. Let oRPC infer types from the procedure chain
  3. Ensure input/output schemas match the contract definition

Once your backend (and any sibling -frontend / -common packages) are ready, package and publish them so operators can install via the runtime Plugin Manager. The full guide — required package.json shape, the bunx @checkstack/scripts plugin-pack CLI, single-package vs --bundle mode, npm / GitHub release / GitHub Enterprise / tarball-upload distribution, and a copy-paste GitHub Actions workflow — lives in Plugin Distribution & Packing.

For the dev loop itself, add @checkstack/dev-server as a devDependency, wire "dev": "checkstack-dev" into your package.json scripts, and run bun run dev from your plugin’s repo — it boots a local Checkstack with your plugin loaded and auth bypassed. (bunx @checkstack/dev-server also works as a one-shot before any install.) Full guide: Developing Plugins in Isolation.

Quick checklist before your first release:

  1. Add a pack script to every package’s package.json:
    "scripts": { "pack": "bunx @checkstack/scripts plugin-pack" }
  2. Set the required checkstack block (type, pluginId) and standard metadata fields (description, author, license).
  3. For multi-package plugins, declare checkstack.bundle on the primary only — all siblings ship at the same version.
  4. Run bun run pack (or bun run pack -- --bundle for bundles) locally to verify metadata before pushing the release tag.
  5. Use the release workflow template as a starting point for CI.