Cookbook
Practical recipes for common workflow patterns. Every snippet is valid code against the current ghagen API — copy, adapt, and run ghagen synth.
Matrix build across Python versions
Section titled “Matrix build across Python versions”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"),],)import { job, step, strategy, checkout, setupPython } from "@ghagen/ghagen";
const test = job({ name: "Test (Python ${{ matrix.python-version }})", runsOn: "ubuntu-latest", timeoutMinutes: 15, strategy: strategy({ matrix_: { "python-version": ["3.11", "3.12", "3.13"], }, }), steps: [ checkout(), setupPython({ version: "${{ matrix.python-version }}" }), step({ name: "Install", run: "pip install -e '.[dev]'" }), step({ name: "Test", run: "pytest" }), ],});Matrix over multiple OSes
Section titled “Matrix over multiple OSes”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"),],)import { job, step, raw, strategy, checkout, setupPython } from "@ghagen/ghagen";
job({ name: "Cross-platform test", runsOn: raw("${{ matrix.os }}"), timeoutMinutes: 20, strategy: strategy({ matrix_: { 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(), setupPython({ version: "${{ matrix.python-version }}" }), step({ name: "Test", run: "pytest" }), ],});Cache dependencies
Section titled “Cache dependencies”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"),]import { step, checkout, setupPython } from "@ghagen/ghagen";
const steps = [ checkout(), setupPython({ 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" }),];Job dependencies with needs
Section titled “Job dependencies with needs”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 }}"),],),}import { job, step, checkout } from "@ghagen/ghagen";
const jobs = { build: job({ runsOn: "ubuntu-latest", timeoutMinutes: 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({ runsOn: "ubuntu-latest", needs: "build", if_: "github.ref == 'refs/heads/main'", timeoutMinutes: 10, steps: [ step({ run: "echo Releasing ${{ needs.build.outputs.version }}" }), ], }),};Pass artifacts between jobs
Section titled “Pass artifacts between jobs”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/*"),],),}import { job, step, checkout } from "@ghagen/ghagen";
const jobs = { build: job({ runsOn: "ubuntu-latest", timeoutMinutes: 15, steps: [ checkout(), step({ name: "Build wheel", run: "python -m build" }), step({ name: "Upload artifact", uses: "actions/upload-artifact@v4", with_: { name: "dist", path: "dist/" }, }), ], }), publish: job({ runsOn: "ubuntu-latest", needs: "build", timeoutMinutes: 10, steps: [ step({ name: "Download artifact", uses: "actions/download-artifact@v4", with_: { name: "dist", path: "dist/" }, }), step({ name: "Publish", run: "twine upload dist/*" }), ], }),};Use secrets and environment variables
Section titled “Use secrets and environment variables”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",),],)import { job, step, checkout, secrets } from "@ghagen/ghagen";
job({ name: "Publish release notes", runsOn: "ubuntu-latest", timeoutMinutes: 10, env: { GH_TOKEN: secrets.GITHUB_TOKEN, RELEASE_CHANNEL: "stable", }, steps: [ checkout(), step({ name: "Comment on release", run: "gh release edit ${{ github.ref_name }} --notes-file NOTES.md", }), ],});Scheduled (cron) workflows
Section titled “Scheduled (cron) workflows”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"),],),},)import { workflow, job, step, on, scheduleTrigger, workflowDispatch, checkout,} from "@ghagen/ghagen";
const nightly = workflow({ name: "Nightly Build", on: on({ schedule: [scheduleTrigger({ cron: "0 9 * * *" })], workflowDispatch: workflowDispatch({}), }), jobs: { build: job({ runsOn: "ubuntu-latest", timeoutMinutes: 30, steps: [ checkout(), step({ name: "Nightly build", run: "make nightly" }), ], }), },});Reusable workflows with workflow_call
Section titled “Reusable workflows with workflow_call”Define a workflow that accepts workflow_call inputs and reference it from
another workflow by passing uses on a job.
Callee (.github/workflows/deploy.yml)
Section titled “Callee (.github/workflows/deploy.yml)”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"),],),},)import { workflow, job, step, on, permissions, workflowCall, checkout,} from "@ghagen/ghagen";
const deploy = workflow({ name: "Deploy", on: on({ workflowCall: workflowCall({ inputs: { environment: { description: "Target environment", required: true, type: "string", }, }, }), }), permissions: permissions({ contents: "read", idToken: "write" }), jobs: { deploy: job({ runsOn: "ubuntu-latest", environment: "${{ inputs.environment }}", timeoutMinutes: 15, steps: [ checkout(), step({ name: "Deploy", run: "./deploy.sh" }), ], }), },});Caller
Section titled “Caller”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"])},)import { job, secrets } from "@ghagen/ghagen";
const release = job({ name: "Deploy to production", uses: "./.github/workflows/deploy.yml", with_: { environment: "production" }, secrets: { DEPLOY_KEY: secrets.DEPLOY_KEY },});Multiline scripts
Section titled “Multiline scripts”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 buildecho "Testing..."make test""",)import { step } from "@ghagen/ghagen";
step({ name: "Build and test", run: `echo "Building..."make buildecho "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 """,)step({ name: "Deploy if ready", run: `if [ "$READY" = "true" ]; then ./deploy.shelse 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" """,)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}`);""")},)import { step } from "@ghagen/ghagen";
step({ uses: "actions/github-script@v7", with_: { script: `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.
options: auto_dedent: falseConditional jobs and steps
Section titled “Conditional jobs and steps”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'",),],)import { job, step, checkout } from "@ghagen/ghagen";
job({ name: "Deploy", runsOn: "ubuntu-latest", timeoutMinutes: 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'", }), ],});Composite action alongside your workflows
Section titled “Composite action alongside your workflows”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.ymlimport { App, action, actionInputDef, branding, compositeRuns, step,} from "@ghagen/ghagen";
const checkAction = action({ name: "My Check", description: "Run the project's canonical checks", branding: branding({ icon: "check-circle", color: "green" }), inputs: { config: actionInputDef({ description: "Path to config file", required: false, default: "config.toml", }), }, runs: compositeRuns({ using: "composite", steps: [ step({ name: "Run checks", run: 'mycheck --config "${{ inputs.config }}"', shell: "bash", }), ], }),});
const app = new App();app.addAction(checkAction);await app.synth();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.