Skip to content

Callback Contract

The framework calls your workflows (callbacks) during CI/CD execution. This document defines the contract your workflows must follow.

Every callback (validate, build, deploy, publish) is a reusable workflow that you declare with workflow: in the manifest. The framework invokes it with workflow_call.

Migrating from inline run:/shell: callbacks

Section titled “Migrating from inline run:/shell: callbacks”

Inline run:/shell: callbacks were removed. A callback can no longer carry a run: script or a shell: setting in the manifest; it must point at a reusable workflow via workflow:. The manifest still parses these keys, but validation now rejects them.

To migrate, move the script into a reusable workflow under .github/workflows/, expose it with on: workflow_call (declaring the standard environment, sha, and dry_run inputs), and replace the callback’s run:/shell: with workflow: .github/workflows/<name>.yaml. The script text becomes a run: step inside that workflow’s job. The sections below show the required structure for each callback type.

Adopting repositories provide callback workflows that the framework invokes:

Framework Your Repository
┌─────────────────┐ ┌──────────────────┐
│ orchestrate.yaml│──workflow_call──▶│ validate.yaml │
│ │──workflow_call──▶│ build-app.yaml │
│ │──workflow_call──▶│ deploy-cdk.yaml │
│ │ │ │
│ promote.yaml │──workflow_call──▶│ deploy-svc.yaml │
│ │──workflow_call──▶│ publish.yaml │
└─────────────────┘ └──────────────────┘
TypePurposeStandard Inputs
ValidatePre-build checks (lint, test)environment, sha, dry_run
BuildProduce artifactsenvironment, sha, dry_run
DeployApply changes to an environmentenvironment, sha, dry_run, plus build outputs
PublishRetag artifacts at the prerelease->release boundarybuild_name, old_version, new_version, sha, artifact_id

The framework always passes these to validate/build/deploy callbacks:

InputTypeDescription
environmentstringTarget environment (e.g., dev, test, prod)
shastringCommit SHA being processed
dry_runbooleanIf true, skip mutating operations

Any other inputs your callback needs must come from one of:

  1. Static inputs: in the manifest
  2. Per-environment env_inputs: in the manifest
  3. Outputs declared by a depends_on: callback (auto-discovered)

The framework parses your workflow files to discover declared outputs: and forwards them to dependents as inputs by name.

Build workflows produce artifacts (Docker images, binaries).

name: Build App
on:
workflow_call:
inputs:
environment:
type: string
required: true
sha:
type: string
required: true
dry_run:
type: boolean
required: false
default: false
# Add custom inputs as needed
outputs:
artifact_id:
description: Immutable artifact identifier (e.g., image digest)
value: ${{ jobs.build.outputs.artifact_id }}
# Add custom outputs as needed
OutputRequiredDescription
artifact_idRecommendedImmutable artifact identifier (image digest, binary checksum). Captured to state and passed to publish callbacks.

Additional declared outputs are forwarded to dependent deploys as inputs by name.

name: Build App
on:
workflow_call:
inputs:
environment:
type: string
required: true
sha:
type: string
required: true
dry_run:
type: boolean
required: false
default: false
dockerfile:
type: string
required: false
default: ./Dockerfile
outputs:
artifact_id:
description: Image digest
value: ${{ jobs.build.outputs.digest }}
image_tag:
description: Built image tag
value: ${{ jobs.build.outputs.tag }}
jobs:
build:
runs-on: ubuntu-latest
outputs:
digest: ${{ steps.push.outputs.digest }}
tag: ${{ steps.meta.outputs.tag }}
steps:
- uses: actions/checkout@v4
with:
ref: ${{ inputs.sha }}
- uses: docker/setup-buildx-action@v3
- name: Generate tag
id: meta
run: |
TAG="${{ github.sha }}-$(date +%s)"
echo "tag=$TAG" >> "$GITHUB_OUTPUT"
- name: Build image
uses: docker/build-push-action@v5
with:
context: .
file: ${{ inputs.dockerfile }}
push: false
load: true
tags: myrepo/app:${{ steps.meta.outputs.tag }}
- name: Push image
id: push
if: ${{ !inputs.dry_run }}
run: |
docker push myrepo/app:${{ steps.meta.outputs.tag }}
DIGEST=$(docker inspect --format='{{index .RepoDigests 0}}' \
myrepo/app:${{ steps.meta.outputs.tag }} | cut -d@ -f2)
echo "digest=$DIGEST" >> "$GITHUB_OUTPUT"

Deploy workflows apply changes to an environment.

name: Deploy Services
on:
workflow_call:
inputs:
environment:
type: string
required: true
sha:
type: string
required: true
dry_run:
type: boolean
required: false
default: false
# Plus any outputs from depends_on builds (e.g., image_tag, artifact_id)
outputs:
# Add custom outputs as needed

When a deploy declares depends_on: [app] and the app build declares an image_tag output, the deploy callback receives image_tag as an input automatically:

on:
workflow_call:
inputs:
environment:
type: string
required: true
sha:
type: string
required: true
image_tag:
type: string
required: true # Provided by the framework via output chaining
name: Deploy Services
on:
workflow_call:
inputs:
environment:
type: string
required: true
sha:
type: string
required: true
image_tag:
type: string
required: true
dry_run:
type: boolean
required: false
default: false
cluster:
type: string
required: false
default: default
jobs:
deploy:
runs-on: ubuntu-latest
environment: ${{ inputs.environment }} # Enables environment protection
steps:
- uses: actions/checkout@v4
with:
ref: ${{ inputs.sha }}
- name: Configure AWS
uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::123456789:role/deploy-role
aws-region: us-east-1
- name: Deploy
if: ${{ !inputs.dry_run }}
run: |
aws ecs update-service \
--cluster ${{ inputs.cluster }} \
--service my-service \
--force-new-deployment \
--task-definition my-task:${{ inputs.image_tag }}

Optional pre-build validation.

name: Validate
on:
workflow_call:
inputs:
environment:
type: string
required: true
sha:
type: string
required: true
dry_run:
type: boolean
required: false
default: false
name: Validate
on:
workflow_call:
inputs:
environment:
type: string
required: true
sha:
type: string
required: true
dry_run:
type: boolean
required: false
default: false
check_lint:
type: boolean
required: false
default: true
jobs:
validate:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
ref: ${{ inputs.sha }}
- uses: actions/setup-go@v5
with:
go-version: "1.25"
- name: Lint
if: ${{ inputs.check_lint }}
run: golangci-lint run ./...
- name: Test
run: go test -v ./...

The publish callback runs once per build at the prerelease->release boundary (when a draft RC is published as a final semver release). Use it to retag artifacts that still carry their RC version.

name: Publish
on:
workflow_call:
inputs:
build_name:
type: string
required: true
old_version:
type: string
required: true
new_version:
type: string
required: true
sha:
type: string
required: true
artifact_id:
type: string
required: false
InputDescription
build_nameWhich build’s artifacts to retag (matches a builds[].name)
old_versionRC version currently in the registry (e.g., v1.0.0-rc.2)
new_versionFinal semver to apply (e.g., v1.0.0)
shaGit commit SHA
artifact_idImmutable digest from the build’s artifact_id output (empty if not declared)
name: Publish
on:
workflow_call:
inputs:
build_name:
type: string
required: true
old_version:
type: string
required: true
new_version:
type: string
required: true
sha:
type: string
required: true
artifact_id:
type: string
required: false
jobs:
retag:
runs-on: ubuntu-latest
steps:
- name: Pull and retag
run: |
docker pull myrepo/${{ inputs.build_name }}:${{ inputs.old_version }}
docker tag \
myrepo/${{ inputs.build_name }}:${{ inputs.old_version }} \
myrepo/${{ inputs.build_name }}:${{ inputs.new_version }}
docker push myrepo/${{ inputs.build_name }}:${{ inputs.new_version }}

The framework only carries metadata. The publish callback performs the registry operation. When artifact_id is present, use it instead of old_version so the target is unambiguous.

Pass custom inputs via inputs and env_inputs in the manifest:

ci:
config:
builds:
- name: app
workflow: .github/workflows/build-app.yaml
inputs:
dockerfile: ./docker/Dockerfile.prod
build_args: "VERSION=1.0.0"
env_inputs:
prod:
sign_image: true

Your workflow receives them as inputs:

on:
workflow_call:
inputs:
dockerfile:
type: string
build_args:
type: string
sign_image:
type: boolean

Outputs from one callback are passed to dependents:

ci:
config:
builds:
- name: app
workflow: .github/workflows/build-app.yaml
# Outputs declared in the workflow: artifact_id, image_tag
deploys:
- name: services
workflow: .github/workflows/deploy-services.yaml
depends_on: [app]
# Receives: artifact_id, image_tag as inputs

The framework parses workflow files for outputs: and forwards them automatically.

The framework automatically captures into per-environment state:

FieldSourceWhere
artifact_idBuild’s artifact_id outputstate.<env>.builds.<name>.artifact_id

artifact_id is the canonical identifier passed to publish callbacks at release time.

ci:
state:
dev:
builds:
app:
sha: abc123
built_at: "2026-01-15T10:25:00Z"
artifact_id: "sha256:def456..."

Use GitHub Environment protection for approval gates. Because every deploy is a reusable workflow, declare the environment: key on the job inside your reusable workflow. GitHub Actions only allows a job-level environment: key on a steps job, never on a job that calls a reusable workflow with uses:, so the caller job cascade generates cannot carry it.

cascade passes the target environment name to your workflow as the environment input, so wire it through:

# your reusable deploy workflow
jobs:
deploy:
runs-on: ubuntu-latest
environment: ${{ inputs.environment }} # protection lives here
steps:
- run: ./deploy.sh

cascade cannot set environment: on the caller job it generates: GitHub Actions rejects a workflow that puts environment: on a uses: job. cascade therefore emits only the with: environment: input on the caller and relies on your reusable workflow to apply the protection rules. cascade prints a generate-time note when gha_environment is configured for an environment, reminding you to declare environment: inside the reusable workflow.

Configure protection in GitHub: Settings -> Environments -> Add required reviewers.

All callbacks should respect dry_run:

- name: Deploy
if: ${{ !inputs.dry_run }}
run: |
# Actual deployment
- name: Dry run preview
if: ${{ inputs.dry_run }}
run: |
echo "Would deploy ${{ inputs.image_tag }}"

Callback failures are handled by the on_failure policy:

PolicyBehavior
abortFail the entire workflow
continueOther callbacks proceed

With retries: N, failed callbacks retry up to N times before final failure.

  • Build -> produce artifacts
  • Deploy -> apply to environment
  • Validate -> check quality
  • Publish -> retag
build-{name}.yaml
deploy-{name}.yaml
validate.yaml
publish.yaml

The framework discovers outputs by parsing your workflow files. Declare them under on.workflow_call.outputs:

outputs:
artifact_id:
description: Image digest
value: ${{ jobs.build.outputs.digest }}
Terminal window
# Use act to invoke workflow_call locally
act workflow_call -j build \
--input environment=dev \
--input sha=$(git rev-parse HEAD) \
--input dry_run=true