Workflow Reuse
This document defines the v0.x workflow reuse model. The execution-instance split, template triggering, and job-level reusable Workflow calls are implemented. Step-level reusable Action execution remains planned.
Current State
The current API provides:
WorkflowRunexecution instances with immutable inlinejobs;- reusable
Workflowdefinitions with declared inputs and output contracts; krt wf trigger, which validates template inputs and materializes an inline WorkflowRun;- inline step
runscripts,needsdependencies, and bounded step/job outputs; and - job-level reusable Workflow calls with local snapshots, frozen output contracts, nested calls, and late-bound input rendering.
Reusable Action calls are represented in the API but are not yet executed by the controller.
Goals
- Rename the execution instance API to
WorkflowRun. - Reuse the
Workflowkind for reusable workflow definitions. - Add an
Actionkind for reusable step groups. - Keep first-version references namespace-local and short:
uses: <name>. - Use
withfor inputs. - Keep reusable Actions inside the caller job context.
- Give reusable Workflow calls their own job/workspace/artifact boundary.
- Keep validation strict so each object has one clear shape.
Non-Goals
- No cross-namespace, remote, Git, OCI, or marketplace references in the first version.
- No GitHub Actions compatibility promise.
- No matrix strategy in this design.
- No UI or run history design beyond the CRD status shapes required for v0.x.
- No backwards-compatible migration requirement for the current experimental
Workflowexecution instance API.
API Overview
The target split is:
| Kind | Role |
|---|---|
WorkflowRun | Execution instance with inline jobs. |
Workflow | Reusable workflow definition. It is triggered into an inline WorkflowRun or called from a job. |
Action | Reusable step group. Can be called from a step inside a WorkflowRun or Workflow. |
WorkflowRun
WorkflowRun is the object that executes work. It always contains inline jobs.
Inline form:
apiVersion: kruntimes.io/v1alpha1
kind: WorkflowRun
metadata:
name: release-demo
spec:
jobs:
build:
runs-on: bash
steps:
- name: package
run: |
echo "building"
krt workflow trigger build-and-test --input image=agent:v0.1.0 resolves the
template inputs and creates an equivalent inline WorkflowRun. WorkflowRun
does not expose a top-level uses or with API.
Reusable Workflow
Workflow becomes a reusable definition. It is not itself an execution
instance.
apiVersion: kruntimes.io/v1alpha1
kind: Workflow
metadata:
name: build-and-test
spec:
inputs:
image:
type: string
required: true
outputs:
image:
value: ${{ jobs.build.outputs.image }}
jobs:
build:
runs-on: bash
steps:
- name: package
run: |
echo "image=${{ inputs.image }}" >> "$KRUNTIME_OUTPUTS"
A job can also call a reusable Workflow:
apiVersion: kruntimes.io/v1alpha1
kind: WorkflowRun
metadata:
name: release-demo
spec:
jobs:
release:
uses: build-and-test
with:
image: agent:v0.1.0
Validation must enforce that job uses and job steps are mutually exclusive.
Reusable Workflow jobs have their own job/workspace/artifact boundary. They communicate with callers through inputs, outputs, and artifacts. Each call creates an inline child WorkflowRun with its own local snapshot; the child snapshot retains the source Workflow output contract, but no shared root-wide execution tree. The concrete execution boundary is defined in Job-Level Reusable Workflow Execution .
Action
Action is a reusable step group.
apiVersion: kruntimes.io/v1alpha1
kind: Action
metadata:
name: setup-python-tools
spec:
inputs:
version:
type: string
default: "3.12"
outputs:
python-version:
value: ${{ steps.setup.outputs.python-version }}
steps:
- name: setup
run: |
echo "python-version=${{ inputs.version }}" >> "$KRUNTIME_OUTPUTS"
echo "installing toolchain"
Steps call an Action with uses:
apiVersion: kruntimes.io/v1alpha1
kind: WorkflowRun
metadata:
name: build-demo
spec:
jobs:
build:
runs-on: bash
steps:
- name: setup
uses: setup-python-tools
with:
version: "3.13"
- name: package
run: |
echo "using ${{ steps.setup.outputs.python-version }}"
Validation must enforce that step uses and step run are mutually exclusive.
Actions run inside the caller job context. By default they share the caller job runtime, workspace, artifacts, environment, and placement constraints. This makes Actions lightweight step composition, not a nested workflow execution. The concrete status, snapshot, and execution behavior is defined in Action Execution .
Inputs and Outputs
The first version should support simple typed string inputs:
inputs:
image:
type: string
required: true
version:
type: string
default: "3.12"
Outputs should be expression-based:
outputs:
image:
value: ${{ jobs.build.outputs.image }}
For v0.x, validation should prefer a narrow model:
- input
typestarts withstring; requiredanddefaultare mutually constrained;withvalues are strings;- missing required inputs fail validation or reconcile early;
- unknown input names fail validation or reconcile early.
The trigger client binds inputs before creating an inline root WorkflowRun. The WorkflowRun controller binds inputs when it creates a ready child call:
- Start from the callee’s declared
inputs. - Apply each input
default. - Overlay caller-provided
withvalues. - Reject missing required inputs.
- Reject unknown
withkeys. - Render inputs into the child WorkflowRun’s inline jobs before creating it.
Once a WorkflowRun exists, its inline jobs and local snapshot are immutable. Reusable Workflow calls are late-bound: a template can change before a waiting call job becomes runnable, but cannot change a child WorkflowRun after it has been created. The child’s snapshot retains the source output contract so parent output projection remains deterministic after template changes. Later work can add explicit template revisioning for earlier binding.
Step outputs come from child Run results. A step writes small key-value outputs
to KRUNTIME_OUTPUTS; runtimed persists them to the child Run status. The
WorkflowRun controller reads those child Run outputs and promotes them into the
matching ordered WorkflowRun.status.jobs.<job>.steps[] entry.
Job outputs are evaluated after all steps in the job succeed and stored in
WorkflowRun.status.jobs.<job>.outputs. A reusable Workflow’s declared outputs
are evaluated from the successful child WorkflowRun’s local job status and
projected into the caller job’s same outputs map. Output evaluation must fail
the affected job when an expression references a missing job, step, or output
key.
Reference Resolution
The first version should keep references namespace-local:
uses: build-and-test
uses: setup-python-tools
Do not introduce workflowRef, actionRef, cross-namespace references, remote
URLs, Git refs, or OCI refs until there is a concrete need.
This keeps the API small and avoids creating a reference format that must be supported long-term before the execution model is stable.
Reference resolution should happen in this order:
krt workflow triggerresolves a selected reusable Workflow and creates an inline root WorkflowRun.- Resolve each ready job-level
usesto a same-namespace reusableWorkflow. - Represent each runnable reusable Workflow call as a child WorkflowRun with its own job/workspace/artifact boundary, using the immutable execution snapshot defined in Job-Level Reusable Workflow Execution .
- Resolve each step-level
usesto a same-namespaceAction. - Expand Action steps inline inside the caller job context.
- Detect cycles before creating any child Runs.
Cycles must be rejected across Workflow calls. An Action must not call another Action in the first version because nested Action expansion is not needed yet and makes cycle detection harder. A reusable Workflow may call another Workflow only when the controller can prove the call graph is acyclic.
Resolution failures should set the WorkflowRun to Failed before creating any
child Runs. Examples include missing references, wrong namespace assumptions,
unsupported nested Action calls, input binding failures, and cycles.
Execution Graph
The WorkflowRun controller owns graph expansion and execution state. It should not rely on scheduler or runtimed to understand Workflow concepts.
The first implementation should use a simple deterministic graph model:
- every job has a stable execution path local to its owning WorkflowRun;
- every step has a stable execution path, such as
jobs.build.steps.package; - each child Run is labeled with its owning WorkflowRun, local job, and local step identity;
- child Run names are generated deterministically enough for idempotent reconciliation, or are discovered through labels before creating new Runs;
- the controller creates Runs only when all dependency jobs have succeeded;
- terminal child Run phases are preserved on their owning step, then aggregated into job and WorkflowRun state according to the terminal semantics below.
The first version should support one execution strategy:
- Accept the WorkflowRun and set
status.phase=Pending. - Resolve references and bind inputs.
- Persist resolved predecessor job edges in
status.jobs[*].pre. - Start every runnable step: the first step of each dependency-ready job and the next step after a successful predecessor in a running job.
- When a step Run succeeds, collect outputs; the following reconciliation includes its next step with any other runnable steps.
- Aggregate observed terminal step states into the job state: all succeeded steps succeed the job, while any failed step fails it. Job output evaluation is deferred until output propagation is implemented.
- After all executable jobs have reached a terminal state, evaluate WorkflowRun outputs and determine its terminal state.
This deliberately avoids adding a separate WorkflowRunInvocation API. Child Runs remain the durable execution records, and scheduler/runtimed continue to operate only on Runs.
The WorkflowRun controller should keep reconciliation structured as
load/calculate/apply/patch: load the WorkflowRun and all child Runs, derive the
desired status and current state from those resources, calculate one action,
apply the action, incorporate its result into the desired status, and patch
status only when it differs from the persisted status. Status projection is
part of every reconciliation; observing child Runs and aggregating terminal
steps into job phases are not separate actions. Current state and action remain
intentionally separate. The initial Empty state has an Initialize action,
which validates controller-level semantics, resolves references and inputs,
persists the execution graph, and sets Accepted=True. A failed initialization
sets Accepted=False and does not create child Runs. Later execution actions
must not modify Accepted: an accepted WorkflowRun can still fail while
executing.
A reconciliation must not loop through multiple external actions before the
status update. One StartRunnableSteps action may materialize every currently
runnable step, including steps made runnable by status derived at the start of
that reconciliation. Each job contributes at most one target, so the action
does not advance a job through multiple execution operations in the same
reconciliation. If the action fails, the controller returns the error without
patching status. If it succeeds, created Run identities and running phases are
added to the desired status before the single conditional status patch. This
keeps external operations explicit, idempotent, and restart-safe without using
extra reconciliations for internal status projection.
Failure, Cancellation, and Terminal Semantics
The v0.x default follows the familiar GitHub Actions job-dependency model:
independent jobs run in parallel, while a failed or skipped prerequisite skips
its dependents. Conditional execution, continue-on-error, and matrix
fail-fast behavior are deliberate future API additions; they are not implicit
controller behavior in the first version.
- A terminal child Run is copied to the matching step without rewriting its
phase. In particular,
RunTimeoutremainsRunTimeout, andCancelledremainsCancelled. - A job succeeds only when all of its steps succeed. A failed, cancelled, or
timed-out step makes its owning job
Failed. - Independent jobs continue to be created and allowed to finish after another
job fails. A job that depends, directly or transitively, on a failed or
skipped job is marked
Skippedand never creates a child Run. Itspreedges and predecessor job phases identify the blocker, so it is not itselfFailed. - The controller waits until every executable job has reached a terminal state
or been skipped. The WorkflowRun is
Failedif any job failed; otherwise it isSucceeded, including the case where jobs were skipped only because of a dependency. WorkflowRun status must preserve the job-level reasons so the aggregate phase is explainable. - Cancelling a WorkflowRun prevents new child Runs from being created and
requests cancellation for every non-terminal child Run. Once those children
have settled, the WorkflowRun is
Cancelled; it is not converted toFailedbecause a child reports cancellation or timeout during this process.
API Prerequisites
The existing WorkflowRun API cannot represent all of these semantics. Before the controller implements dependency propagation or cancellation, the API must add the following fields and phases:
apiVersion: kruntimes.io/v1alpha1
kind: WorkflowRun
spec:
cancelRequested: true
status:
phase: Cancelled
jobs:
test:
phase: Skipped
pre: [build]
WorkflowRun.spec.cancelRequestedis a user intent, mirroringRun.spec.cancelRequested. Once observed, the controller must not create more child Runs for that WorkflowRun.WorkflowPhasemust add terminalCancelled.JobPhasemust add terminalSkipped. It means the job was not executed because a predecessor failed or was skipped. The existingpreedges plus predecessor job phases identify the blocking job, so v0.x does not add a redundantblockedBystatus field.JobPhasedoes not addCancelledin v0.x. A step cancelled as ordinary execution failure makes its running jobFailed; cancellation of the whole WorkflowRun is represented by the parentCancelledphase.
Cancellation is a separate controller action. It takes priority over normal
execution actions, patches spec.cancelRequested=true on every non-terminal
child Run, and waits for their terminal phases to be observed. It then sets the
parent WorkflowRun to Cancelled. Jobs that were never started retain their
current Pending or Waiting state because they were not skipped by a DAG
dependency; the parent terminal phase explains why they will not run.
Outside cancellation, dependency propagation and WorkflowRun finalization are default status derivations performed before planning external actions:
- mark a Pending or Waiting job
Skippedwhen any predecessor isFailedorSkipped; independent jobs remain eligible to start; - after all executable jobs have settled, set WorkflowRun
Failedwhen any job isFailed, otherwise set itSucceeded.
The API change requires regenerated CRDs and controller RBAC allowing the WorkflowRun controller to patch child Runs for cancellation.
Inline WorkflowRun execution should land in small, reviewable steps:
- Before changing execution behavior, audit the existing E2E tests and update
affected cases so
make e2eremains passing throughout the implementation. - Create only the first child Run for each ready inline job, record the child Run name on the matching ordered step status, and make creation idempotent by discovering existing child Runs through labels.
- Before adding more execution states, refactor the WorkflowRun controller into a load/calculate/apply/patch shape: load the WorkflowRun and related resources, derive desired status and current state, calculate one external action, apply it, incorporate its result, and conditionally patch status.
- Watch or reconcile child Runs owned by a WorkflowRun and copy terminal child Run phase into the matching step status.
- Define and review failure, cancellation, and terminal-status semantics:
independent jobs continue after a failure, dependency-blocked jobs become
Skipped, and the WorkflowRun aggregates only after all executable jobs settle. - When a step succeeds and a later step is pending, create the next step Run in the same job.
- Aggregate terminal step states into terminal job states: all succeeded steps succeed the job; any failed, cancelled, or timed-out step fails it.
- Add the reviewed terminal-status and cancellation API prerequisites, regenerate CRDs, and grant child Run patch RBAC.
- Mark jobs
Skippedwhen a failed or skipped predecessor blocks them; when a job succeeds, unblock jobs whosepredependencies have all succeeded. - Finalize a non-cancelled WorkflowRun as
SucceededorFailedafter all executable jobs settle. - Handle
spec.cancelRequestedby cancelling active child Runs and finalizing the WorkflowRun asCancelled. - Add restart recovery tests that prove the controller can continue from
status.jobs[*].steps[*].runNameand child Run labels without duplicating Runs. - Add E2E coverage only after the controller can execute an inline WorkflowRun end to end.
Expression Context
For v0.x, expressions should stay intentionally small. They should support only string interpolation from known contexts:
| Context | Available from |
|---|---|
inputs.<name> | resolved inputs for the current Workflow, Action, or WorkflowRun |
steps.<step>.outputs.<name> | previous steps in the same job |
jobs.<job>.outputs.<name> | completed dependency jobs in the same graph boundary |
Expressions should not access Kubernetes objects, environment variables, secrets, files, arbitrary functions, or network resources. Secret handling needs a separate design before it is exposed to Workflow expressions.
Evaluation must be deterministic and side-effect free. Unsupported syntax or missing values should fail the WorkflowRun with a clear condition and message.
Status Model
WorkflowRun.status owns execution state:
status:
phase: Running
jobs:
build:
phase: Running
pre: []
steps:
- name: package
phase: Succeeded
outputs:
image: agent:v0.1.0
test:
phase: Waiting
pre:
- build
steps:
- name: unit
phase: Pending
Workflow.status and Action.status should contain definition-level
conditions only, such as validation or readiness. They should not contain
per-execution job or step state.
The first implementation stores only lightweight DAG edges and ordered step
status for inline WorkflowRun.spec.jobs. It does not store full job specs,
step commands, environment, or source data in status.
Component Boundaries
| Component | Responsibility |
|---|---|
| WorkflowRun controller | Expands inline jobs, resolves reusable Workflow and Action references, creates child Runs, and updates execution status. |
| Workflow controller | Validates reusable Workflow definitions and exposes definition conditions. |
| Action controller | Validates reusable Action definitions and exposes definition conditions. |
| Scheduler | Schedules child Runs only. It does not know about Workflow reuse. |
| runtimed | Executes child Runs only. It does not know about Workflow reuse. |
Breaking Change
This is a breaking API change from the current experimental Workflow model:
- current
Workflowexecution instances becomeWorkflowRun; Workflowbecomes reusable definition only;- no compatibility shim is required because Workflow is still experimental and not part of a stable API promise.
Docs, examples, CLI verbs, CRDs, and E2E tests must be updated together when the implementation lands.
Implementation Sequence
- Add this design document and review the API shape.
- Add
WorkflowRunAPI types, CRD validation, status, and controller skeleton. - Change
WorkflowAPI types to reusable definitions. - Add
ActionAPI types, CRD validation, status, and controller skeleton. Namespace-local resolution, input binding, output propagation, and WorkflowRun execution are separate follow-up implementation steps. - Implement
krt workflow triggerto validate inputs, render a reusable Workflow, and create an inline root WorkflowRun. - Implement per-WorkflowRun snapshots and direct child WorkflowRun creation for ready job-level calls.
- Implement inline WorkflowRun first-step Run creation for ready jobs.
- Refactor WorkflowRun controller reconciliation into a load/calculate/apply/patch structure with default status projection and external side effects represented as actions.
- Implement child Run status observation and step status updates.
- Define and review child failure, cancellation, dependency propagation, and
WorkflowRun terminal-status semantics: independent jobs continue, blocked
dependents are
Skipped, and terminal status is aggregated after all executable jobs settle. - Implement next-step creation after observed step success.
- Implement job terminal-state aggregation from observed step states.
- Add terminal-status and cancellation API prerequisites, regenerated CRDs, and child Run patch RBAC.
- Implement failed-dependency propagation to
JobSkipped. - Implement WorkflowRun terminal aggregation.
- Implement WorkflowRun cancellation propagation.
- Verify controller restart recovery for in-progress inline WorkflowRuns, including child Run creation before status persistence.
- Implement job-level reusable Workflow calls.
- Implement step-level Action expansion.
- Implement expression evaluation and output propagation.
- Update CLI verbs and docs to use
WorkflowRunfor execution. - Add E2E coverage for inline
WorkflowRun, reusable Workflow calls, Action calls, validation failures, output propagation, and controller restart recovery from the status DAG edges. - Update the final v0.x demos after the reusable model is implemented.
Current implementation status:
WorkflowRun,Workflow, andActionAPI skeletons exist.Workflowis now a reusable definition skeleton and no longer executes child Runs.- Inline WorkflowRuns initialize
status.jobs[*].preand orderedstatus.jobs[*].steps. - WorkflowRun template triggering and job-level reusable Workflow calls remain pending implementation.
- Inline WorkflowRun job DAGs reject unknown dependencies and multi-job cycles before status graph initialization or child Run creation.
- Inline WorkflowRuns create first-step and next-step child Runs for runnable jobs and record child Run names in ordered step status.
- WorkflowRuns observe terminal child Run phases, copy them into matching step status, aggregate terminal job phases, and finalize after all jobs settle. Any failed job fails the WorkflowRun; otherwise it succeeds, including when remaining jobs are skipped.
- WorkflowRun cancellation stops new child Run creation, idempotently requests
cancellation for active child Runs, and finalizes as
Cancelledafter they settle. Jobs that never started retain theirPendingorWaitingphase. - Restart recovery is verified across the create-before-status-patch failure window: a replacement controller discovers child Runs through durable labels, repairs step status, and continues terminal observation without duplicates.