Skip to main content

A formatting tool for GitHub Workflows and Actions

Project description

gh-formatter

CI PyPI version Python versions License: MIT

A powerful and customizable formatting tool for GitHub Actions and Workflows. Automatically format and lint your workflow YAML files with consistent style, naming conventions, and structure.

Features

  • Automatic Formatting: Format GitHub Actions (action.yml/action.yaml) and Workflow files (.github/workflows/*.yml/yaml)
  • Customizable Rules: Apply custom formatting rules including:
    • Key ordering
    • List style standardization
    • List indentation
    • Quote style normalization (single/double)
    • Alphabetical sorting of env/inputs/outputs/secrets/with blocks
    • Name capitalization
    • Job naming conventions
    • Input naming conventions
    • if: expression normalization (wraps bare conditions in ${{ }})
    • Custom style rules
  • Multiple Modes:
    • format: Format files in-place
    • --check: Dry-run mode to verify formatting without making changes
    • --diff: Show unified diff of what would be changed
  • Caller Input Checking: Errors (or auto-fixes) when a uses: ./... caller passes an input the local target doesn't declare
  • Custom Configuration: Support for custom configuration files to enforce your team's style guide
  • Inline Disable Directives: Exempt a whole file, a region, or a single line with # gh-formatter:disable[-file|-line] / :enable
  • Recursive Discovery: Automatically finds all workflow and action files in your project

Installation

From PyPI

pip install gh-formatter

From Source

git clone https://github.com/nimpsch/gh-formatter.git
cd gh-formatter
pip install -e .

Use as a pre-commit hook

Add this to your .pre-commit-config.yaml:

repos:
- repo: https://github.com/nimpsch/gh-formatter
  rev: v0.1.0  # use the latest release tag
  hooks:
  - id: gh-formatter

Requirements

  • Python 3.11 or higher
  • ruamel.yaml >= 0.17.21

Usage

Basic Formatting

Format a single file:

gh-formatter .github/workflows/main.yml

Format all workflows in a directory:

gh-formatter .github/workflows/

Format the entire project (will find all actions and workflows):

gh-formatter .

Check Mode (Dry Run)

Check if files need formatting without modifying them:

gh-formatter --check .github/workflows/

Exit code will be 1 if any files need formatting, 0 if all are already formatted.

Show Differences

See what changes would be made:

gh-formatter --diff .github/workflows/

Displays a unified diff for each file that would be changed.

Custom Configuration

Use a custom configuration file:

gh-formatter --config my-config.yml .github/workflows/

Listing Rules

See every available rule and post-processor (and its id, used for toggling):

gh-formatter --list-rules

Disabling formatting with inline directives

Sometimes a file (or a few lines) is formatted intentionally and you want gh-formatter to leave it alone. Add a YAML comment directive (yamllint-style):

Directive Effect
# gh-formatter:disable-file Skip the entire file.
# gh-formatter:disable# gh-formatter:enable Skip every line in the region between the two directives.
# gh-formatter:disable-line Skip the line the directive trails, or — when it sits on its own line — the next line.
# gh-formatter:disable-file   # nothing in this file is touched

name: ci
on: push
jobs:
  # gh-formatter:disable
  keepThisExactly:        # name, order and quotes are all preserved
    runs-on: ubuntu-latest
  # gh-formatter:enable

  normal_job:             # formatted normally
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: echo hi
        legacyInput: x    # gh-formatter:disable-line  (left untouched)

A disabled key/step/region is exempt from every rule: renaming, key ordering, name capitalization, list style, quote normalization, and whitespace cleanup. Renames are also suppressed across files, so a frozen reusable-workflow input is not rewritten in its callers.

Because gh-formatter parses and re-emits the document, the base indentation still applies even inside a disabled region — directives turn off the content rules, not the YAML serializer.

Configuration

Configuration is read from .gh-formatter.yml, .gh-formatter.yaml, or the [tool.gh-formatter] table in pyproject.toml (searched in that order), or from an explicit --config path. All options are optional; defaults shown:

# Indentation
indent: 2            # general mapping indentation
sequence_indent: 4   # indentation of list item content
sequence_offset: 2   # indentation of the list dash (must be < sequence_indent)
                     # default indents list items one level under their key:
                     #   steps:
                     #     - name: ...

# Naming conventions ("dash-case" or "snake_case")
input_casing: dash-case
job_casing: snake_case
# true keeps env-var style names uppercase (SERVER-IMAGE -> SERVER_IMAGE);
# false (default) renames them like any other name
preserve_uppercase_names: false

# Line endings: "preserve" (default) or "lf"
line_endings: preserve

# Quote style for already-quoted scalars: "double" (default), "single",
# or "preserve". Plain unquoted values are never force-quoted.
quote_style: double

# Let a yamllint config drive indentation (see "Using with yamllint").
# false (default), true (auto-discover .yamllint*), or an explicit path.
defer_to_yamllint: false

# Mapping blocks sorted alphabetically (case-insensitive); [] disables.
alphabetize: [env, inputs, outputs, secrets, with]

# Trigger filter lists under `on:` (branches, tags, paths, ...)
list_style: block    # "block" (- a) or "flow" ([a, b])
list_keys:           # which keys under `on:` are treated as filter lists
  - branches
  - branches-ignore
  - tags
  - tags-ignore
  - paths
  - paths-ignore
  - types
  - workflows

# Blank lines
blank_line_between_steps: true
blank_line_between_jobs: true

# Key ordering (unlisted keys keep their relative order at the end)
key_order_workflow: [name, run-name, on, concurrency, permissions, env, defaults, jobs]
key_order_action: [name, description, author, inputs, outputs, runs, branding]
key_order_job: [name, if, needs, runs-on, uses, with, secrets, permissions,
                environment, concurrency, strategy, container, services,
                outputs, env, defaults, timeout-minutes, continue-on-error,
                steps]
key_order_step: [name, if, id, uses, run, with, env, working-directory,
                 shell, timeout-minutes, continue-on-error]

# Disable individual rules by id (see `gh-formatter --list-rules`)
rules: {}
# rules:
#   capitalize-names: false
#   blank-lines: false

Invalid option names or values are rejected with a clear error message (exit code 2).

Safety guarantees

  • Renames that would collide with an existing input/job name are skipped and reported as a warning instead of silently overwriting a definition.
  • name keys inside with:/env: blocks (e.g. artifact names) are never capitalized — only workflow, job, and step display names are.
  • Filter-list normalization only applies inside the on: section, so a step input that happens to be called branches is left alone.
  • Script contents (run: | blocks) are never touched by blank-line insertion.
  • Original line endings (LF/CRLF) and an explicit --- document start marker are preserved.

Cross-file input consistency

Renaming the inputs of a reusable workflow (workflow_call) or a local action changes its public interface, so callers referencing it via uses: ./... can fall out of sync. gh-formatter checks each caller's with: keys against the local target's declared inputs and, by default, errors on a mismatch so you fix both files (see Caller input checking for the error / fix / ignore modes).

With caller_inputs: fix, gh-formatter instead renames the caller's keys to match — when you run it on the repository root (gh-formatter .) so the definition and its callers are formatted together:

jobs:
  call_template:
    uses: ./.github/workflows/template.yml
    with:
      commit-sha: abc123   # fixed to match the template's input

Either way, only uses: ./... references whose target is part of the same run are considered — marketplace actions (actions/checkout@v4) are never touched.

To turn off input renaming of definitions entirely:

rules:
  input-naming: false

Caller input checking

When a caller passes a with: key that the local target does not declare — typically a casing or rename that drifted between the two files — gh-formatter acts according to the caller_inputs option. Only local references (uses: ./...) whose target is part of the same run are considered; marketplace actions (actions/checkout@v4) are never checked.

# .gh-formatter.yml
caller_inputs: error   # error (default) | fix | ignore
Mode Behavior
error (default) Report the mismatch as an error and fail the run, like a linter. You fix it in both files.
fix Rename the caller's key to the matching declared input (at your own risk).
ignore Leave caller inputs alone.

In error mode the message points at the offending key and the likely fix:

[error] caller.yml - with: input 'commitSha' is not declared by local target
        './.github/workflows/template.yml' (did you mean 'commit-sha'?) - fix it in both files

Errors fail the run (non-zero exit) so CI catches the drift; run gh-formatter --check . in CI. Use fix to let gh-formatter rename caller keys for you, or ignore to turn the check off.

Using with yamllint

gh-formatter is a formatter (it rewrites files); yamllint is a linter (it reports style problems). They complement each other, but yamllint's defaults flag a few things gh-formatter intentionally produces, so out of the box the two would fight. This repo ships a .yamllint.yml that resolves the conflicts — drop the same file into your project and both tools agree.

What the bundled config changes and why:

yamllint rule Setting Reason
line-length disable gh-formatter never wraps run: scripts or ${{ }} expressions, so a width cap would flag its output.
document-start disable gh-formatter preserves an existing --- but never inserts one.
truthy check-keys: false The Actions on: key is read as a YAML 1.1 boolean; this stops it being flagged while still checking values.
indentation indent-sequences: consistent Matches gh-formatter's indented sequences while tolerating hand-written files that keep dashes flush.

Run order matters: lint after formatting so yamllint sees the final output.

gh-formatter .          # format first
yamllint --strict .     # then lint

The same ordering is wired into CI and the pre-commit hooks.

Tip: in your own config files (like .gh-formatter.yml) quote a literal "on" in a list so YAML 1.1 linters don't read it as true.

Keeping the two configs in sync

The bundled .yamllint.yml is deliberately lenient about indentation (indent-sequences: consistent), so it accepts gh-formatter's output whatever the indent width. But if you tighten yamllint to a specific width — say indentation: {spaces: 4, indent-sequences: true} — while gh-formatter is still on its 2-space default, the two diverge: gh-formatter reindents to 2, yamllint demands 4, and the project never goes green.

To make that impossible, point gh-formatter at the yamllint config and let yamllint win:

# .gh-formatter.yml
defer_to_yamllint: true          # discover .yamllint(.yml/.yaml) in the cwd
# defer_to_yamllint: path/to/.yamllint.yml   # or an explicit path

When enabled, gh-formatter reads the yamllint indentation rule and derives its own indent / sequence_indent / sequence_offset from it (mapping the spaces width and indent-sequences flag, always leaving exactly one space after a - so the hyphens rule is happy too). yamllint becomes the single source of truth, so the formatter can't produce output its own linter rejects. If yamllint leaves the width as consistent, there is nothing concrete to copy and gh-formatter keeps its configured indentation.

Examples

See the examples/ directory for sample workflow and action files:

  • workflow_example.yml: Basic workflow example
  • reusable_workflow_example.yml: Reusable workflow example
  • caller_workflow_example.yml: Workflow that calls reusable workflows

Development

Setup Development Environment

# Clone the repository
git clone https://github.com/nimpsch/gh-formatter.git
cd gh-formatter

# Create a virtual environment
python -m venv .venv
source .venv/bin/activate  # On Windows: .venv\Scripts\activate

# Install the package in editable mode with development dependencies
pip install -e ".[dev]"

Running Tests

pytest

Code Quality

The project uses several tools for code quality:

  • black: Code formatting
  • ruff: Fast Python linting
  • mypy: Static type checking

Run all quality checks:

black src/ tests/
ruff check src/ tests/
mypy src/
pytest

Project Structure

gh-formatter/
├── src/
│   └── gh_formatter/
│       ├── cli.py           # Command-line interface
│       ├── casing.py        # Casing conversion + safe rename planning
│       ├── comments.py      # Comment-preserving key reordering
│       ├── config.py        # Configuration loading and validation
│       ├── context.py       # Processing context (file type, warnings, directives)
│       ├── directives.py    # Inline disable directives (gh-formatter:disable*)
│       ├── discovery.py     # Workflow/action file discovery
│       ├── engine.py        # Pipeline: rules -> dump -> post-processors
│       ├── postprocess.py   # Text post-processors (blank lines)
│       ├── project.py       # Cross-file rename planning
│       ├── references.py    # Expression reference rewriting
│       ├── utils.py         # ruamel round-trip + shared tree traversal
│       ├── yamllint_sync.py # Derive indentation from a yamllint config
│       └── rules/           # Tree formatting rules
│           ├── alphabetize.py  # Alphabetical block sorting rule
│           ├── base.py      # Base rule class
│           ├── inputs.py    # Input naming rule
│           ├── callers.py   # Cross-file caller input renaming rule
│           ├── jobs.py      # Job naming rule
│           ├── keys.py      # Key ordering rule
│           ├── lists.py     # Trigger filter list style rule
│           ├── names.py     # Display name capitalization rule
│           ├── quotes.py    # Quote style normalization rule
│           └── style.py     # Whitespace/boolean style rule
├── tests/                   # Test suite
├── examples/                # Example workflow files
└── README.md               # This file

Contributing

Contributions are welcome! Please read CONTRIBUTING.md for development setup, the project layout, how to add a new rule, and the pull request process. By participating you agree to the Code of Conduct.

To report a bug or request a feature, open an issue using the provided templates. For security issues, see SECURITY.md.

License

This project is licensed under the MIT License - see the LICENSE file for details.

Author

Sebastian Nimpsch

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

gh_formatter-0.1.0.tar.gz (62.4 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

gh_formatter-0.1.0-py3-none-any.whl (47.1 kB view details)

Uploaded Python 3

File details

Details for the file gh_formatter-0.1.0.tar.gz.

File metadata

  • Download URL: gh_formatter-0.1.0.tar.gz
  • Upload date:
  • Size: 62.4 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/6.1.0 CPython/3.13.12

File hashes

Hashes for gh_formatter-0.1.0.tar.gz
Algorithm Hash digest
SHA256 d7ff1f8751f46e30bcbac9060fd20d738142ebbecbaddee9d83c39545b111fa1
MD5 2fae450bb218647479e1f67eda06b180
BLAKE2b-256 26ee19056e807e03e5274db2db96d6729950cdb575d3cb35f77437e08d4f61b3

See more details on using hashes here.

Provenance

The following attestation bundles were made for gh_formatter-0.1.0.tar.gz:

Publisher: release.yml on nimpsch/gh-formatter

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file gh_formatter-0.1.0-py3-none-any.whl.

File metadata

  • Download URL: gh_formatter-0.1.0-py3-none-any.whl
  • Upload date:
  • Size: 47.1 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/6.1.0 CPython/3.13.12

File hashes

Hashes for gh_formatter-0.1.0-py3-none-any.whl
Algorithm Hash digest
SHA256 5f77f0c7e70a29a5ffad41684b562af315846508a57b56d85fe27e7c073c8ed5
MD5 e1f1d6db1bddf838e1994109f5b292a8
BLAKE2b-256 cad6328efcb290936664879c13730577b5b7c64e714a314a6c16b4b8e42bb20a

See more details on using hashes here.

Provenance

The following attestation bundles were made for gh_formatter-0.1.0-py3-none-any.whl:

Publisher: release.yml on nimpsch/gh-formatter

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page