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=[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: pytest

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

That step renders in its job’s steps: list as:

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

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

The name field renders as:

# 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 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")])},
)

The on field renders as:

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:

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

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

This produces the following YAML:

# The name shown in the GitHub UI
name: Commented Workflow
on: # trigger configuration
push:
branches:
- main
jobs:
lint:
# Run linters before tests
name: Lint
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Ruff # fast Python linter
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, 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.

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 — 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 written
name: CI
on:
push:
branches:
- main
jobs:
build:
runs-on: ubuntu-latest
steps:
- run: make

Two 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 written followed by a bare #.
  • An empty-string header is a header, not a skip — it renders a single bare #. Only None / null suppresses 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.

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.