Skip to content

Escape Hatches

ghagen’s typed models cover the most common GitHub Actions features, but the GitHub Actions schema is large and evolving. When you need something the models don’t provide, ghagen offers graduated escape hatches — each trading increasing flexibility for decreasing type safety.

raw() wraps a value to bypass validation on a single field. The value is emitted as-is into the YAML output. Use this when a field has a constrained type (e.g., a Literal or Enum) but you need to pass an expression or a value outside the allowed set.

from ghagen import Job, Raw, Step
# Use an expression where a literal runner label is expected
Job(runs_on=Raw("${{ matrix.os }}"))
# Use a custom shell that isn't in the predefined set
Step(name="Custom shell", run="echo hello", shell=Raw("custom-shell"))

Step.uses, Step.run, and other plain-string fields don’t need raw — you can put a ${{ ... }} expression directly in the string. Reach for raw only when a field has a constrained type (like shell’s predefined set or runsOn’s runner labels).

raw is the lightest escape hatch. It preserves type safety on all other fields and only bypasses the constraint on the specific field you wrap.

The extras dictionary lets you add key-value pairs that are merged into the model’s YAML output. Use this when a model doesn’t have a typed field for a key you need.

from ghagen import Job
Job(
runs_on="ubuntu-latest",
extras={
"timeout-minutes": 30,
"continue-on-error": True,
},
)

This produces:

runs-on: ubuntu-latest
timeout-minutes: 30
continue-on-error: true

extras keys are added after the model’s own fields. Values can be any YAML-serializable type — strings, numbers, booleans, lists, or nested dicts.

Set postProcess to a callback that runs just before the model is written out. It receives the underlying YAML node and can freely add keys, remove keys, reorder entries, or modify values.

from ghagen import On, PushTrigger, Workflow
def add_annotation(cm):
cm["x-generated-by"] = "ghagen"
Workflow(
name="Annotated",
on=On(push=PushTrigger(branches=["main"])),
jobs={"test": test_job},
post_process=add_annotation,
)

Use post_process for conditional logic, complex mutations, or anything that doesn’t fit the declarative model:

import os
def add_debug_step(cm):
if os.environ.get("CI_DEBUG"):
cm["jobs"]["test"]["steps"].insert(
0, {"name": "Debug info", "run": "env | sort"}
)
Workflow(..., post_process=add_debug_step)

For maximum flexibility, you can build a raw YAML node and pass it anywhere a typed model is expected. This bypasses the type system entirely and gives you full control over the YAML structure.

from ruamel.yaml.comments import CommentedMap
from ghagen import Job, On, PushTrigger, Step, Workflow
cm_job = CommentedMap()
cm_job["runs-on"] = "ubuntu-latest"
cm_job["steps"] = [{"run": "echo 'raw job'"}]
Workflow(
name="Mixed",
on=On(push=PushTrigger(branches=["main"])),
jobs={
"raw": cm_job,
"typed": Job(
runs_on="ubuntu-latest",
steps=[Step(uses="actions/checkout@v4")],
),
},
)

Typed models and raw YAML nodes can be mixed freely in the same workflow — use typed models where they fit and drop down to a CommentedMap only for the sections that need escape.

Use this decision guide to pick the right escape hatch:

SituationEscape hatch
A field rejects your value (expression, custom enum)raw()
You need a YAML key that has no corresponding model fieldextras
You need conditional logic or complex mutations at emission timepostProcess
You need full control over a section of YAMLRaw YAML passthrough

Start with raw() and escalate only if it doesn’t solve your problem. The lighter the escape hatch, the more type safety and IDE support you retain.