Skip to content

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.

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=[...],
)

Generated YAML:

test:
# Run the full test suite against all supported Python versions
name: Test
runs-on: ubuntu-latest

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",
)

Generated YAML:

- name: Ruff
run: ruff check . # fast Python linter

Wrap a field value with with_comment() / withComment() to add a comment above that field in the output:

from ghagen import with_comment
Workflow(
name=with_comment("CI", "The name shown in the GitHub UI"),
on=On(push=PushTrigger(branches=["main"])),
jobs={...},
)

Generated YAML:

# The name shown in the GitHub UI
name: CI

Wrap a field value with with_eol_comment() / withEolComment() to add an end-of-line comment after that field’s value:

from ghagen import with_eol_comment
Workflow(
name="CI",
on=with_eol_comment(On(push=PushTrigger(branches=["main"])), "trigger configuration"),
jobs={...},
)

Generated YAML:

name: CI
on: # trigger configuration
push:
branches:
- main

Both with_comment and with_eol_comment are chainable — you can wrap a value with both a block comment and an EOL comment:

python name=with_eol_comment(with_comment("CI", "Block comment"), "EOL comment")

Here is a workflow that uses all four comment types:

from ghagen import 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",
),
],
),
},
)

This produces the following YAML:

# The name shown in the GitHub UI
name: Commented Workflow
on: # trigger configuration
push:
branches:
- main
jobs:
lint:
name: Lint
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- # fast Python linter
name: Ruff
run: ruff check .

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:

# 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.

App(header=...) accepts four shapes:

ValueBehavior
omit (default)Emit ghagen’s default header.
NoneEmit no header.
strEmit the string verbatim. No {variable} substitution — literal braces are preserved.
(vars: HeaderVariables) => strInvoke the closure with a fully-populated HeaderVariables and emit the returned string.

HeaderVariables carries this fixed set of variables:

VariableDescription
source_fileSource file that defined this item, relative to the app root.
source_lineLine number where the Workflow / Action was constructed.
toolThe string ghagen.
versionThe 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."
),
)

Emits:

# 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 #.

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.

EOL comments on steps (and other mapping items inside sequences): When a step has an eolComment, it renders on its own line above the step rather than at the end of the first line. The comment is still attached to the right node and appears in the output — only its visual placement differs from a true end-of-line comment.