Example Pipelines¶
These examples demonstrate patterns that are difficult or impossible in traditional YAML-based CI/CD systems. Each example is annotated with what it does and why it works the way it does.
All examples are in examples/.forge/. The CI scripts they reference are in examples/scripts/ci/.
Example 1: Dynamic Matrix Build¶
File: examples/.forge/dynamic-matrix.yml
Script: examples/scripts/ci/generate-matrix.py
What it does¶
Generates one build job and one test job per target platform at runtime, based on a config file. All jobs run in parallel. A release step fans in after everything passes.
Why it's different from GitHub Actions¶
GitHub Actions matrix values must be declared in YAML before the workflow runs:
# GitHub Actions — static, must be updated manually
strategy:
matrix:
os: [linux, windows, darwin]
arch: [amd64, arm64]
exclude:
- os: windows
arch: arm64
In Forge, the generator script reads platforms.json at runtime:
- id: matrix-generator
type: generator
image: python:3.12-slim
script: examples/scripts/ci/generate-matrix.py
Add a new platform to platforms.json → pipeline adapts. Set SKIP_WINDOWS=1 → Windows builds are skipped for that run. Neither requires editing YAML.
The generator script¶
generate-matrix.py reads platforms.json (or uses defaults), then emits one build step + one test step per platform:
def steps_for_platform(p: dict) -> list[dict]:
build_step = {
"id": f"build-{p['os']}-{p['arch']}",
"image": p["image"],
"env": {"GOOS": p["os"], "GOARCH": p["arch"]},
"run": f"go build -o dist/myapp-{p['os']}-{p['arch']} ./cmd/myapp",
"artifacts": {"upload": [{"path": f"dist/myapp-{p['os']}-{p['arch']}"}]}
}
# test step runs in parallel (no dependency on build step)
test_step = { ... }
return [build_step, test_step]
The fan-in point — depends_on: [matrix-generator] on the release step — means "wait for the generator AND all of its emitted children." Forge resolves this automatically.
platforms.json¶
[
{"os": "linux", "arch": "amd64", "image": "golang:1.24-alpine"},
{"os": "linux", "arch": "arm64", "image": "golang:1.24-alpine"},
{"os": "windows", "arch": "amd64", "image": "golang:1.24-alpine"},
{"os": "darwin", "arch": "amd64", "image": "golang:1.24-alpine"},
{"os": "darwin", "arch": "arm64", "image": "golang:1.24-alpine"}
]
Example 2: Static Matrix Build¶
File: examples/.forge/static-matrix.yml
What it does¶
Runs the same test suite across multiple Node.js versions and operating systems in parallel using the built-in matrix field.
name: static-matrix-example
steps:
- id: test
image: node:${{ matrix.version }}
matrix:
version: [18, 20, 22]
os: [linux, alpine]
run: |
echo "Testing Node ${{ matrix.version }} on ${{ matrix.os }}"
node --version
npm test
How it works¶
The Forge compiler expands this step into 6 distinct jobs: test-18-linux, test-18-alpine, test-20-linux, etc. Variables are accessed using the ${{ matrix.key }} syntax.
Example 3: Reusable Step Templates¶
Files: examples/.forge/templates-demo.yml, examples/.forge/templates/go-build.yml
What it does¶
Demonstrates how to extract common step definitions into reusable template files.
templates/go-build.yml:
image: golang:1.24-alpine
env:
CGO_ENABLED: "0"
run: go build -o dist/${{ env.BINARY_NAME }} ./cmd/${{ env.BINARY_NAME }}
templates-demo.yml:
steps:
- id: build-api
uses: templates/go-build.yml
env:
BINARY_NAME: api-server
- id: build-worker
uses: templates/go-build.yml
env:
BINARY_NAME: worker-process
Why use templates?¶
Templates reduce duplication and ensure consistency across multiple projects. If you need to update your base Go image or build flags, you only need to change it in one place.
Example 4: Monorepo Smart CI¶
File: examples/.forge/monorepo-smart-ci.yml
Script: examples/scripts/ci/detect-changes.py
What it does¶
Detects which services in a monorepo have changed files, and runs lint + test + build + push for only those services. Unchanged services produce zero jobs. If shared/ changes, all services are rebuilt.
Why it's different¶
Traditional approaches:
- Option A: Build everything — slow for large monorepos, wastes resources
- Option B: Per-service paths: filters — separate workflow file per service, adding a service means adding a workflow, hard to maintain
- Option C: GitHub's dorny/paths-filter action + dynamic matrix — requires experimental features, limited output format
Forge approach — one generator step, one script:
- id: change-detector
type: generator
image: python:3.12-slim
script: examples/scripts/ci/detect-changes.py
env:
REGISTRY: registry.example.com
BASE_REF: "${BASE_REF:-HEAD~1}"
The change detection script¶
def main():
changed_paths = git_changed_paths(BASE_REF)
# If shared/ changed, rebuild everything
if any(p.startswith("shared/") for p in changed_paths):
affected = discover_services() # all services
else:
affected = [svc for svc in discover_services()
if any(p.startswith(f"services/{svc}/") for p in changed_paths)]
steps = []
for svc in affected:
steps.extend(build_steps_for_service(svc))
print(json.dumps(steps))
Each service gets exactly four steps with correct dependencies:
All services run fully in parallel with each other.
Adding a new service¶
Add a directory under services/. No pipeline changes needed — the detector script discovers services dynamically.
Example 5: Progressive Deployment with Pipeline Chaining¶
Files: examples/.forge/progressive-deploy.yml, examples/.forge/deploy.yml
What it does¶
Builds a Go application, containerizes it, deploys to staging (child pipeline), runs integration tests against staging, then deploys to production (same child pipeline, different variables). Artifacts flow between parent and child pipelines.
The key insight: one deploy file, used everywhere¶
The deploy logic lives in .forge/deploy.yml. It's called three times — once for staging, once for canary, once for production — with different variables injected each time:
# In progressive-deploy.yml
- id: deploy-staging
type: pipeline
pipeline: .forge/deploy.yml
variables:
ENVIRONMENT: staging
NAMESPACE: myapp-staging
REPLICAS: "2"
artifacts_send: [container-image] # pass the built image
artifacts_receive: [deployed-endpoint] # get back the URL
- id: deploy-production
type: pipeline
pipeline: .forge/deploy.yml
depends_on: [smoke-tests] # only runs if tests pass
variables:
ENVIRONMENT: production
NAMESPACE: myapp-production
REPLICAS: "10"
artifacts_send: [container-image] # same image, different target
Artifact flow¶
graph LR
Build[build] -- "app-binary" --> Containerize[containerize]
Containerize -- "container-image" --> Staging[deploy-staging<br/>child pipeline]
Staging -- "deployed-endpoint" --> Smoke[smoke-tests]
Smoke -- "tests staging URL" --> Production[deploy-production<br/>child pipeline]
Containerize -- "container-image" --> Production
The child deploy pipeline¶
deploy.yml is a standalone pipeline that:
1. Loads the container image from the artifact store
2. Pushes it to the container registry
3. Deploys via Helm
4. Returns the deployed endpoint URL as an artifact
It has no hardcoded environment names — all configuration comes from injected variables.
In GitHub Actions¶
This pattern requires:
- workflow_call: with inputs: and outputs: declarations for each variable
- Separate artifacts: upload/download steps in every workflow
- Manual output mapping between caller and called workflow
- No way to pass binary artifacts between workflows at all
Example 6: Cross-Team Library Release¶
File: examples/.forge/library-release.yml
Script: examples/scripts/ci/discover-consumers.py
What it does¶
When a library is released: 1. Tests and packages the library 2. A generator step (Python script) discovers which consumer services depend on it 3. Triggers each consumer's integration test suite in parallel as child pipelines 4. Only publishes if ALL consumer tests pass 5. Notifies the team
Why it's hard in traditional CI¶
To trigger downstream repos in GitHub Actions, you need:
- repository_dispatch events or workflow_dispatch triggers in each consumer repo
- Each consumer team must maintain their workflow to watch for your event
- No unified view of all downstream results
- You don't know if downstream tests passed before you publish
In Forge, Team A's pipeline owns the whole flow:
- id: discover-consumers
type: generator
image: python:3.12-slim
script: examples/scripts/ci/discover-consumers.py
- id: publish
depends_on: [discover-consumers] # waits for ALL consumer tests
The consumer registry¶
discover-consumers.py reads .forge/consumer-registry.json:
[
{"name": "auth-service", "team": "platform", "pipeline": ".forge/integration-test.yml"},
{"name": "api-gateway", "team": "api", "pipeline": ".forge/integration-test.yml"},
{"name": "notification-svc","team": "notifications", "pipeline": ".forge/integration-test.yml"}
]
Adding a new consumer: add an entry to consumer-registry.json. The library team doesn't need to edit their release pipeline.
The generated child pipeline steps¶
Each consumer gets a type: pipeline step that:
- Triggers their integration test pipeline
- Passes the library archive artifact
- Passes the library version and parent run ID as variables
- Waits for the test pipeline to complete
All consumers run in parallel — maximum fan-out, fan back in before publish.
Example 7: Conditional Logic and Event Triggers¶
File: examples/.forge/conditionals.yml
What it does¶
Showcases Forge's conditional execution engine. Steps can be configured to run only on success, only on failure, or always, regardless of dependency status. It also demonstrates how to handle PR/MR-specific logic using environment variables.
Why it's different¶
In GitHub Actions, conditional logic is often buried in complex if: expressions:
# GitHub Actions — complex expressions in YAML
if: ${{ always() && (needs.setup.result == 'failure' || needs.test.result == 'failure') }}
Forge uses simple, readable function calls and a dedicated always_run flag:
- id: notify-failure
condition: failure()
depends_on: [unit-tests, integration-tests]
run: echo "❌ Build failed!"
- id: cleanup
always_run: true
run: echo "🧹 Cleaning up..."
Supported Conditions¶
success()(Default): Run only if all dependencies passed.failure(): Run only if at least one dependency failed.always(): Run regardless of dependency status.tag(): Run only if the pipeline was triggered by a Git tag.branch(name): Run only if the pipeline branch matches the name or glob pattern (e.g.,branch(main),branch(feature/*)).always_run: true: A shortcut forcondition: always().
Event-based Logic¶
Forge injects FORGE_EVENT and FORGE_PR_NUMBER environment variables into runs triggered via webhooks. This allows your scripts to perform different actions based on the trigger:
- id: deploy-preview
image: alpine:latest
run: |
if [ "$FORGE_EVENT" = "pull_request" ] || [ "$FORGE_EVENT" = "merge_request" ]; then
echo "🚀 Deploying preview for PR/MR #$FORGE_PR_NUMBER..."
fi
Running the Examples¶
# Build Forge
go build -o forge ./cmd/forge
# Run locally (no scheduler needed)
./forge run examples/.forge/dynamic-matrix.yml
# Submit to a running scheduler
$env:FORGE_API_TOKEN = 'fgt_...'
$env:FORGE_ORG = '<org-id>'
./forge.exe submit examples/.forge/dynamic-matrix.yml
Writing Your Own Generator Scripts¶
Generator scripts follow a simple protocol:
- stdin: nothing (the workspace is mounted at
/workspace) - stdout: a JSON array of step definition objects
- stderr: log output — captured and shown in the Web UI
- exit code: non-zero = generator step failed, no child steps are created
Minimum valid output:
import json
steps = [
{
"id": "my-step",
"image": "alpine:latest",
"depends_on": ["my-generator"], # must include the generator step ID
"run": "echo hello"
}
]
print(json.dumps(steps))
All standard step fields are supported in emitted steps: artifacts, env, secrets, docker_socket, type, etc.