Comments
You can attach comments to any model and they appear in the generated YAML. This is useful for documenting intent, explaining non-obvious configuration, and adding context for anyone reading the generated files.
Comment types
Section titled “Comment types”Block comments
Section titled “Block comments”Set the comment field on any model to render a comment above that node in the YAML output:
Job( name="Test", runs_on="ubuntu-latest", comment="Run the full test suite against all supported Python versions", steps=[Step(run="pytest")],)job({ name: "Test", runsOn: "ubuntu-latest", comment: "Run the full test suite against all supported Python versions", steps: [step({ run: "pytest" })],});That job renders under jobs: as:
test: # Run the full test suite against all supported Python versions name: Test runs-on: ubuntu-latest steps: - run: pytestEnd-of-line comments
Section titled “End-of-line comments”Set the eolComment field to render a comment after the node’s value on the same line:
Step( name="Ruff", run="ruff check .", eol_comment="fast Python linter",)step({ name: "Ruff", run: "ruff check .", eolComment: "fast Python linter",});That step renders in its job’s steps: list as:
- name: Ruff # fast Python linter run: ruff check .Field-level block comments
Section titled “Field-level block comments”Wrap a field value with with_comment() / withComment() to add a comment above that field in the output:
from ghagen import Job, On, PushTrigger, Step, Workflow, with_comment
Workflow( name=with_comment("CI", "The name shown in the GitHub UI"), on=On(push=PushTrigger(branches=["main"])), jobs={"build": Job(runs_on="ubuntu-latest", steps=[Step(run="make")])},)import { job, on, pushTrigger, step, withComment, workflow } from "@ghagen/ghagen";
workflow({ name: withComment("CI", "The name shown in the GitHub UI"), on: on({ push: pushTrigger({ branches: ["main"] }) }), jobs: { build: job({ runsOn: "ubuntu-latest", steps: [step({ run: "make" })] }) },});The name field renders as:
# The name shown in the GitHub UIname: CIField-level EOL comments
Section titled “Field-level EOL comments”Wrap a field value with with_eol_comment() / withEolComment() to add an end-of-line comment after that field’s value:
from ghagen import Job, On, PushTrigger, Step, Workflow, with_eol_comment
Workflow( name="CI", on=with_eol_comment(On(push=PushTrigger(branches=["main"])), "trigger configuration"), jobs={"build": Job(runs_on="ubuntu-latest", steps=[Step(run="make")])},)import { job, on, pushTrigger, step, withEolComment, workflow } from "@ghagen/ghagen";
workflow({ name: "CI", on: withEolComment(on({ push: pushTrigger({ branches: ["main"] }) }), "trigger configuration"), jobs: { build: job({ runsOn: "ubuntu-latest", steps: [step({ run: "make" })] }) },});The on field renders as:
on: # trigger configuration push: branches: - mainBoth with_comment and with_eol_comment are chainable — you can wrap a value with both a block comment and an EOL comment:
name=with_eol_comment(with_comment("CI", "Block comment"), "EOL comment")name: withEolComment(withComment("CI", "Block comment"), "EOL comment");Full example
Section titled “Full example”Here is a workflow that uses all four comment types:
from ghagen import ( Job, On, PushTrigger, Step, Workflow, with_comment, with_eol_comment,)
Workflow( name=with_comment("Commented Workflow", "The name shown in the GitHub UI"), on=with_eol_comment(On(push=PushTrigger(branches=["main"])), "trigger configuration"), jobs={ "lint": Job( name="Lint", runs_on="ubuntu-latest", comment="Run linters before tests", steps=[ Step(uses="actions/checkout@v4"), Step( name="Ruff", run="ruff check .", eol_comment="fast Python linter", ), ], ), },)import { job, on, pushTrigger, step, withComment, withEolComment, workflow } from "@ghagen/ghagen";
workflow({ name: withComment("Commented Workflow", "The name shown in the GitHub UI"), on: withEolComment(on({ push: pushTrigger({ branches: ["main"] }) }), "trigger configuration"), jobs: { lint: job({ name: "Lint", runsOn: "ubuntu-latest", comment: "Run linters before tests", steps: [ step({ uses: "actions/checkout@v4" }), step({ name: "Ruff", run: "ruff check .", eolComment: "fast Python linter", }), ], }), },});This produces the following YAML:
# The name shown in the GitHub UIname: Commented Workflowon: # trigger configuration push: branches: - mainjobs: lint: # Run linters before tests name: Lint runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Ruff # fast Python linter run: ruff check .Header comments
Section titled “Header comments”Every generated file starts with an auto-generated header comment block. By default it points back at the source file that defined the workflow or action, so the path you see is your own:
# This file is generated by ghagen from .github/ghagen_workflows.py.# Do not edit manually.The source_file path is resolved relative to the directory containing .ghagen.yml (the marker file that identifies your project root). Each workflow/action gets its own source path based on where it was constructed — if you split workflows across multiple files, each emitted YAML names its own origin file.
Customizing the header
Section titled “Customizing the header”App(header=...) accepts four shapes:
| Value | Behavior |
|---|---|
| omit (default) | Emit ghagen’s default header. |
None | Emit no header. |
str | Emit the string verbatim. No {variable} substitution — literal braces are preserved. |
(vars: HeaderVariables) => str | Invoke the closure with a fully-populated HeaderVariables and emit the returned string. |
HeaderVariables carries this fixed set of variables:
| Variable | Description |
|---|---|
source_file | Source file that defined this item, relative to the app root. |
source_line | Line number where the Workflow / Action was constructed. |
tool | The string ghagen. |
version | The installed ghagen version (e.g. 0.2.1). |
To interpolate any of these into your header, supply a closure:
app = App( header=lambda v: ( f"Generated by {v['tool']} {v['version']} from {v['source_file']}.\n" "Edit the Python source and run `ghagen synth` instead of hand-editing this file." ),)import type { HeaderVariables } from "ghagen";
const app = new App({ header: (v: HeaderVariables) => `Generated by ${v.tool} ${v.version} from ${v.source_file}.\n` + "Edit the TypeScript source and run `ghagen synth` instead of hand-editing this file.",});Emits — with your own source path, and whichever ghagen version is installed:
# Generated by ghagen 0.2.1 from .github/ghagen_workflows.py.# Edit the Python source and run `ghagen synth` instead of hand-editing this file.A plain string is emitted verbatim, with no substitution — literal { and } survive untouched. Multiline strings (and multiline closure return values) are fine; every line gets a leading # , blank lines render as a bare #.
The YAML body follows the header immediately, with no blank line between them:
# Hand writtenname: CIon: push: branches: - mainjobs: build: runs-on: ubuntu-latest steps: - run: makeTwo further shape rules, identical in both ports:
- One trailing line break in the header string is dropped, so
"Hand written\n"and"Hand written"emit the same bytes. A second trailing break is not:"Hand written\n\n"emits# Hand writtenfollowed by a bare#. - An empty-string header is a header, not a skip — it renders a single bare
#. OnlyNone/nullsuppresses the header.
CRLF, a bare CR, and the remaining Python splitlines() breaks (VT, FF, FS, GS, RS, NEL, U+2028, U+2029) all count as line breaks, so no control character survives into the emitted comment.
To skip the header entirely on a single to_yaml() call, pass header=None — this is what ghagen’s own snapshot tests use to decouple body assertions from header text.
Comment geometry
Section titled “Comment geometry”An end-of-line comment sits two columns after the content it annotates, in both
ports. When the annotated value is a nested map or list — a job’s steps, a
step’s with — the comment renders on that key’s own line rather than being
swallowed into the block below it. A multi-line EOL comment cannot sit at the
end of a line at all, so it renders as a block comment above the item instead.
None of this is decided by inspecting the emitted text, so a # inside a run:
script, an env value, or a comment payload is left exactly as written.