Skip to content

Lifecycle hooks

Hooks are callbacks that run at specific points in the execution lifecycle. They let you react to outcomes (cancellation, success, failure) and perform cleanup without affecting the execution flow.

KiCI supports six hook types at three levels (step, job, workflow):

HookWhen it runsAvailable on
onCancelAfter step/job/workflow is cancelledStep, Job, Workflow
cleanupAlways (success, failure, or cancel)Step, Job, Workflow
onSuccessAfter job/workflow succeedsJob, Workflow
onFailureAfter job/workflow failsJob, Workflow
beforeStepBefore each step in a jobJob
afterStepAfter each step in a jobJob
import { workflow, job, step, push } from '@kici-dev/sdk';
export default workflow('deploy', {
on: push({ branches: ['main'] }),
jobs: [
job('deploy-prod', {
runsOn: 'linux',
steps: [
step('deploy', async ({ $ }) => {
await $`kubectl apply -f manifests/`;
}),
],
onCancel: async (ctx) => {
console.log(`Deploy cancelled: ${ctx.outcome.reason}`);
await ctx.$`kubectl rollout undo deployment/app`;
},
cleanup: async (ctx) => {
// Always runs -- release lock, notify team, etc.
await ctx.$`curl -X POST https://slack.com/webhook -d '{"text": "Deploy ${ctx.outcome.status}"}'`;
},
onSuccess: async (ctx) => {
console.log(`Deploy succeeded in ${ctx.outcome.duration}ms`);
},
onFailure: async (ctx) => {
console.log(`Deploy failed at step: ${ctx.outcome.failedStep}`);
},
gracePeriod: 60, // 60 seconds before SIGKILL on cancel
}),
],
});
step('download-artifacts', {
run: async ({ $ }) => {
await $`wget https://artifacts.example.com/build.tar.gz`;
},
onCancel: async (ctx) => {
// Clean up partial downloads
await ctx.$`rm -f build.tar.gz`;
},
cleanup: async (ctx) => {
await ctx.$`rm -rf /tmp/staging`;
},
});
workflow('ci', {
on: push({ branches: ['main'] }),
jobs: [/* ... */],
onCancel: async (ctx) => {
// Notify when any job in the workflow is cancelled
console.log('CI workflow cancelled');
},
cleanup: async (ctx) => {
// Always runs after all jobs complete
console.log(`CI workflow finished with status: ${ctx.outcome.status}`);
},
});

Hook functions receive the same StepContext as regular steps ($, ctx, log, env), plus an outcome object with metadata about the execution result.

interface OutcomeMetadata {
/** Final status of the job/workflow. */
status: 'cancelled' | 'success' | 'failed';
/** Reason for cancellation (e.g., "User requested", "Superseded by run #42"). */
reason?: string;
/** Name of the step that caused failure (for onFailure hooks). */
failedStep?: string;
/** Outputs from all completed steps. */
stepOutputs: Record<string, unknown>;
/** Total execution duration in milliseconds. */
duration: number;
}

Hooks can do everything regular steps can:

  • Run shell commands via $
  • Set environment variables via ctx.setEnv() and prepend to PATH via ctx.addPath()
  • Access previous step outputs via ctx.outputsOf() and ctx.jobOutputs()
  • Publish encrypted secret outputs via ctx.setSecretOutput()
  • Log via log.info(), log.error(), etc.

Each hook has a timeout (default: 5 minutes). You can customize it per-hook:

job('deploy', {
runsOn: 'linux',
steps: [/* ... */],
cleanup: {
run: async (ctx) => {
await ctx.$`./lengthy-cleanup.sh`;
},
timeout: 10 * 60 * 1000, // 10 minutes in ms
},
});

Hooks execute inside-out on cancellation (like stack unwinding):

  1. Step-level cleanup (on the cancelled step)
  2. Job-level onCancel, then cleanup
  3. Workflow-level onCancel, then cleanup

On success: step afterStep (after each step), then job onSuccess + cleanup, then workflow onSuccess + cleanup.

On failure: job onFailure + cleanup, then workflow onFailure + cleanup.

cleanup always runs — regardless of whether the outcome was success, failure, or cancel.

Hooks follow the “one mechanism per concern” principle:

  • Rules control whether a step/job executes (conditional logic)
  • Hooks react to execution outcomes (lifecycle callbacks)

Hooks cannot short-circuit step execution or change the execution flow. They observe and respond.

These job-level hooks run around every step in the job:

job('test', {
runsOn: 'linux',
beforeStep: async (ctx) => {
console.log(`Starting step at ${new Date().toISOString()}`);
},
afterStep: async (ctx) => {
console.log(`Step completed with status: ${ctx.outcome.status}`);
},
steps: [
step('lint', async ({ $ }) => {
await $`pnpm lint`;
}),
step('test', async ({ $ }) => {
await $`pnpm test`;
}),
],
});

afterStep runs immediately after its step, before the next step starts (not deferred to the end of the job).

Step-level rules control whether a step executes, evaluated at runtime by the agent:

import { step, rule, skip, isEventType } from '@kici-dev/sdk';
step('deploy', {
run: async ({ $ }) => {
await $`kubectl apply -f manifests/`;
},
rules: [
rule('only on main pushes', (ctx) => {
if (!isEventType(ctx.event, 'push')) return false;
return ctx.event.payload.ref === 'refs/heads/main';
}),
],
});
// Or use skip() for explicit skip with a reason
step('optional-check', {
run: async ({ $ }) => {
await $`./optional-check.sh`;
},
rules: [skip('not needed in CI', () => true)],
});

When a rule returns false, the step is reported as skipped and subsequent steps continue normally. Skipped steps don’t cause the job to fail.

Step rules have access to runtime context via RuleContext: event (typed discriminated union), changedFiles, env, and $. Both step-level and job-level rules evaluate agent-side at runtime — not on the orchestrator during trigger matching, because the orchestrator only holds the lock file and never loads the workflow code the rule functions live in. A job-level rule runs inside the execution sandbox after the agent is spawned, the repository source is restored, and the workflow module is loaded, so a job that a rule skips still pays the cost of spawning an agent and restoring the repository source before the skip is decided; only the job’s steps are avoided. See how your workflow code executes for where this sits among the three phases.

If a hook throws an error or times out:

  • The job status changes to failed with a compound reason (e.g., “cancelled (onCancel hook failed: Connection timeout)”)
  • Remaining hooks for that level are skipped
  • The failure is visible in the dashboard as a failed hook step
  • Force cancel kills running hooks immediately via SIGKILL

This behavior is consistent across all hook types.


Source: packages/sdk/src/hooks/, packages/sdk/src/types.ts