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
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=[
Step(uses="actions/checkout@v4"),
Step(
uses="actions/setup-python@v5",
with_={"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
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=[
Step(uses="actions/checkout@v4"),
Step(
uses="actions/setup-python@v5",
with_={"python-version": "${{ matrix.python-version }}"},
),
Step(name="Test", run="pytest"),
],
)

Both languages use Step (Python) or step() (TypeScript) with the actions/cache action directly. restore-keys takes a newline-joined string of prefix keys.

from ghagen import Step
steps = [
Step(uses="actions/checkout@v4"),
Step(uses="actions/setup-python@v5", with_={"python-version": "3.12"}),
Step(
name="Cache pip",
uses="actions/cache@v4",
with_={
"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
jobs = {
"build": Job(
runs_on="ubuntu-latest",
timeout_minutes=10,
outputs={"version": "${{ steps.ver.outputs.value }}"},
steps=[
Step(uses="actions/checkout@v4"),
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 }}"),
],
),
}

Both languages use Step (Python) or step() (TypeScript) with the actions/upload-artifact action in the producer and actions/download-artifact in the consumer. needs ensures the producer finishes first.

from ghagen import Job, Step
jobs = {
"build": Job(
runs_on="ubuntu-latest",
timeout_minutes=15,
steps=[
Step(uses="actions/checkout@v4"),
Step(name="Build wheel", run="python -m build"),
Step(
name="Upload artifact",
uses="actions/upload-artifact@v4",
with_={"name": "dist", "path": "dist/"},
),
],
),
"publish": Job(
runs_on="ubuntu-latest",
needs="build",
timeout_minutes=10,
steps=[
Step(
name="Download artifact",
uses="actions/download-artifact@v4",
with_={"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, 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=[
Step(uses="actions/checkout@v4"),
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,
)
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=[
Step(uses="actions/checkout@v4"),
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,
)
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=[
Step(uses="actions/checkout@v4"),
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, write Step.run as a triple-quoted string with natural Python indentation; auto-dedent removes the common leading whitespace when the workflow is emitted (see below). In TypeScript, JavaScript template literals let you write the script without leading indentation in the first place, so there is nothing to dedent when authoring — but toYaml and toData still apply the same emit-time auto-dedent as Python, so a script that does carry leading indentation (e.g. built programmatically) is normalized the same way in both languages.

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_ (a field the emitter never dedents — auto-dedent only ever touches a Step’s run), 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 both languages — to_yaml/toYaml and to_data/toData all dedent each Step’s run string unless you pass auto_dedent=False / autoDedent: false explicitly. To disable it project-wide instead, set auto_dedent = false in your config; this option is supported by both ports.

.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
Job(
name="Deploy",
runs_on="ubuntu-latest",
timeout_minutes=15,
if_="github.event_name == 'push' && github.ref == 'refs/heads/main'",
steps=[
Step(uses="actions/checkout@v4"),
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.