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.
1. raw — Bypass type constraints
Section titled “1. raw — Bypass type constraints”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"))import { job, step, raw } from "@ghagen/ghagen";
// Use an expression where a literal runner label is expectedjob({ runsOn: raw("${{ matrix.os }}") });
// Use a custom shell that isn't in the predefined setstep({ 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.
2. extras — Inject arbitrary YAML keys
Section titled “2. extras — Inject arbitrary YAML keys”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,},)import { job } from "@ghagen/ghagen";
job({ runsOn: "ubuntu-latest", extras: { "timeout-minutes": 30, "continue-on-error": true },});This produces:
runs-on: ubuntu-latesttimeout-minutes: 30continue-on-error: trueextras keys are added after the model’s own fields. Values can be any YAML-serializable type — strings, numbers, booleans, lists, or nested dicts.
3. postProcess — Hook into emission
Section titled “3. postProcess — Hook into emission”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)import { workflow, job, step, on, pushTrigger } from "@ghagen/ghagen";import type { YAMLMap } from "yaml";
workflow({name: "Annotated",on: on({ push: pushTrigger({ branches: ["main"] }) }),jobs: { test: testJob },postProcess: (map: YAMLMap) => {map.set("x-generated-by", "ghagen");},});The TypeScript postProcess callback receives a YAMLMap from the yaml library rather than a plain dict.
4. Raw YAML passthrough
Section titled “4. Raw YAML passthrough”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.
import { workflow, job, step, on, pushTrigger } from "@ghagen/ghagen";import { YAMLMap, Pair, Scalar } from "yaml";
const rawJob = new YAMLMap();rawJob.items.push(new Pair(new Scalar("runs-on"), "ubuntu-latest"));rawJob.items.push(new Pair(new Scalar("steps"), [{ run: "echo 'raw job'" }]),);
workflow({name: "Mixed",on: on({ push: pushTrigger({ branches: ["main"] }) }),jobs: {raw: rawJob,typed: job({runsOn: "ubuntu-latest",steps: [step({ uses: "actions/checkout@v4" })],}),},});Typed models and raw YAMLMap nodes can be mixed freely in the same workflow — use typed models where they fit and drop down to a YAMLMap only for the sections that need escape. This is the TypeScript equivalent of Python’s CommentedMap.
When to use which
Section titled “When to use which”Use this decision guide to pick the right escape hatch:
| Situation | Escape hatch |
|---|---|
| A field rejects your value (expression, custom enum) | raw() |
| You need a YAML key that has no corresponding model field | extras |
| You need conditional logic or complex mutations at emission time | postProcess |
| You need full control over a section of YAML | Raw 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.