Skip to content

Cookbook

Practical recipes for common workflow patterns. Every snippet is valid code against the current ghagen API — copy, adapt, and run ghagen synth.

Run the same job against multiple Python versions. In Python, dynamic matrix dimensions go in Matrix.extras; in TypeScript, they are top-level keys in the matrix_ object. include and exclude are typed fields in both.

from ghagen import Job, Matrix, Step, Strategy, checkout, setup_python
test = Job(
name="Test (Python ${{ matrix.python-version }})",
runs_on="ubuntu-latest",
timeout_minutes=15,
strategy=Strategy(
matrix=Matrix(
extras={"python-version": ["3.11", "3.12", "3.13"]},
),
),
steps=[
checkout(),
setup_python(version="${{ matrix.python-version }}"),
Step(name="Install", run="pip install -e '.[dev]'"),
Step(name="Test", run="pytest"),
],
)

runs_on is typed as a string, so an expression needs Raw (Python) or raw() (TypeScript) to get through. exclude trims unwanted combinations.

from ghagen import Job, Matrix, Raw, Step, Strategy, checkout, setup_python
Job(
name="Cross-platform test",
runs_on=Raw("${{ matrix.os }}"),
timeout_minutes=20,
strategy=Strategy(
matrix=Matrix(
extras={
"os": ["ubuntu-latest", "macos-latest", "windows-latest"],
"python-version": ["3.11", "3.12", "3.13"],
},
exclude=[
{"os": "windows-latest", "python-version": "3.11"},
],
),
),
steps=[
checkout(),
setup_python(version="${{ matrix.python-version }}"),
Step(name="Test", run="pytest"),
],
)

Python provides a cache() helper that takes key and path as positional-friendly kwargs, with restore_keys joined with newlines on emission. In TypeScript, use step() with the actions/cache action directly.

from ghagen import Step, cache, checkout, setup_python
steps = [
checkout(),
setup_python(version="3.12"),
cache(
key="pip-${{ hashFiles('requirements.txt') }}",
path="~/.cache/pip",
restore_keys=["pip-"],
),
Step(name="Install", run="pip install -r requirements.txt"),
Step(name="Test", run="pytest"),
]

A downstream job lists its upstream dependencies with needs (string or list of strings) and can read their outputs via needs.<job>.outputs.*.

from ghagen import Job, Step, checkout
jobs = {
"build": Job(
runs*on="ubuntu-latest",
timeout_minutes=10,
outputs={"version": "${{ steps.ver.outputs.value }}"},
steps=[
checkout(),
Step(
id="ver",
name="Compute version",
run="echo 'value=1.2.3' >> $GITHUB_OUTPUT",
),
],
),
"release": Job(
runs_on="ubuntu-latest",
needs="build",
if*="github.ref == 'refs/heads/main'",
timeout_minutes=10,
steps=[
Step(run="echo Releasing ${{ needs.build.outputs.version }}"),
],
),
}

In Python, use upload_artifact in the producer and download_artifact in the consumer. In TypeScript, use step() with the upload/download artifact actions directly. needs ensures the producer finishes first.

from ghagen import Job, Step, checkout, download_artifact, upload_artifact
jobs = {
"build": Job(
runs_on="ubuntu-latest",
timeout_minutes=15,
steps=[
checkout(),
Step(name="Build wheel", run="python -m build"),
upload_artifact(name="dist", path="dist/"),
],
),
"publish": Job(
runs_on="ubuntu-latest",
needs="build",
timeout_minutes=10,
steps=[
download_artifact(name="dist", path="dist/"),
Step(name="Publish", run="twine upload dist/*"),
],
),
}

In Python, expr.secrets["NAME"] renders as ${{ secrets.NAME }} — wrap it in str() when assigning to a typed dict[str, str] field like env. In TypeScript, use the secrets proxy object (e.g. secrets.GITHUB_TOKEN).

Prefer the typed accessors over hand-written "${{ secrets.FOO }}" strings — they keep typos in secret names out of your workflows and renames ripple through your code.

from ghagen import Job, Step, checkout, expr
Job(
name="Publish release notes",
runs_on="ubuntu-latest",
timeout_minutes=10,
env={
"GH_TOKEN": str(expr.secrets["GITHUB_TOKEN"]),
"RELEASE_CHANNEL": "stable",
},
steps=[
checkout(),
Step(
name="Comment on release",
run="gh release edit ${{ github.ref_name }} --notes-file NOTES.md",
),
],
)

Schedule entries go into the trigger’s schedule field as a list. You can combine them with a workflow dispatch trigger to allow manual runs too.

from ghagen import (
Job,
On,
ScheduleTrigger,
Step,
Workflow,
WorkflowDispatchTrigger,
checkout,
)
nightly = Workflow(
name="Nightly Build",
on=On(
schedule=[ScheduleTrigger(cron="0 9 * * *")],
workflow_dispatch=WorkflowDispatchTrigger(),
),
jobs={
"build": Job(
runs_on="ubuntu-latest",
timeout_minutes=30,
steps=[
checkout(),
Step(name="Nightly build", run="make nightly"),
],
),
},
)

Define a workflow that accepts workflow_call inputs and reference it from another workflow by passing uses on a job.

from ghagen import (
Job,
On,
Permissions,
Step,
Workflow,
WorkflowCallTrigger,
checkout,
)
from ghagen.models.trigger import WorkflowCallInput
deploy = Workflow(
name="Deploy",
on=On(
workflow_call=WorkflowCallTrigger(
inputs={
"environment": WorkflowCallInput(
description="Target environment",
required=True,
type="string",
),
},
),
),
permissions=Permissions(contents="read", id_token="write"),
jobs={
"deploy": Job(
runs_on="ubuntu-latest",
environment="${{ inputs.environment }}",
timeout_minutes=15,
steps=[
checkout(),
Step(name="Deploy", run="./deploy.sh"),
],
),
},
)

A job that references the reusable workflow via uses. Such a job has no steps of its own and inherits its runner from the callee.

from ghagen import Job, expr
release = Job(
name="Deploy to production",
uses="./.github/workflows/deploy.yml",
with\_={"environment": "production"},
secrets={"DEPLOY_KEY": str(expr.secrets["DEPLOY_KEY"])},
)

Both languages support writing inline multiline scripts with natural indentation.

In Python, Step.run auto-dedents triple-quoted strings so you can use natural Python indentation. In TypeScript, JavaScript template literals handle multiline strings naturally — no dedent needed.

from ghagen import Step
Step(
name="Build and test",
run="""
echo "Building..."
make build
echo "Testing..."
make test
""",
)

Relative indentation within the script is preserved:

Step(
name="Deploy if ready",
run="""
if [ "$READY" = "true" ]; then
./deploy.sh
else
echo "Not ready"
fi
""",
)

For shell line continuations, Python requires \\ (double backslash) since \ at end-of-line is a Python line continuation. TypeScript template literals also use \\ for a literal backslash:

Step(
name="Create PR",
run="""
gh pr create \\
--title "My PR" \\
--body "Automated PR"
""",
)

For github-script and other actions that take script content via with_, Python provides the dedent helper. In TypeScript, template literals handle this naturally:

from ghagen import Step, dedent
Step(
uses="actions/github-script@v7",
with\_={"script": dedent("""
const issue = await github.rest.issues.create({
owner: context.repo.owner,
repo: context.repo.repo,
title: 'Automated issue',
});
console.log(`Created #${issue.data.number}`);
""")},
)

Auto-dedent is enabled by default in Python. To disable it project-wide, set auto_dedent = false in your config. This option is Python-only; TypeScript does not perform auto-dedent.

.ghagen.yml
options:
auto_dedent: false

if_ (safe alias for the reserved word if) works on both Job and Step in both languages.

from ghagen import Job, Step, checkout
Job(
name="Deploy",
runs*on="ubuntu-latest",
timeout_minutes=15,
if*="github.event*name == 'push' && github.ref == 'refs/heads/main'",
steps=[
checkout(),
Step(name="Build", run="make build"),
Step(
name="Smoke test (main only)",
run="./smoke-test.sh",
if*="github.ref == 'refs/heads/main'",
),
],
)

You can emit composite actions as well as workflows. Use app.add_action() and the action is written to action.yml (or <dir>/action.yml) next to your repo root.

from ghagen import (
Action,
App,
ActionInput,
Branding,
CompositeRuns,
Step,
)
check_action = Action(
name="My Check",
description="Run the project's canonical checks",
branding=Branding(icon="check-circle", color="green"),
inputs={
"config": ActionInput(
description="Path to config file",
required=False,
default="config.toml",
),
},
runs=CompositeRuns(
steps=[
Step(
name="Run checks",
run='mycheck --config "${{ inputs.config }}"',
shell="bash",
),
],
),
)
app = App()
app.add_action(check_action) # -> ./action.yml

Use this pattern when you want one file to be the source of truth for both your CI workflows and the composite action other repos consume.