Skip to content

Configuration

A config repository is the input to ghfanout: a directory (usually its own Git repository) containing everything the tool needs — which files to distribute, to which repositories, on which branches.

Concepts

ghfanout borrows Kustomize's base + overlay model:

  • base — the content. Directories under base/ hold the actual files to distribute:
    • base/common/ is distributed to every repository, unconditionally
    • Every other directory under base/ is a profile: a named file set (e.g. java-service) that a repository receives only when its overlay opts in
  • overlay — the selection. Each directory under overlays/ corresponds to one target repository (the directory name is the destination repository name), and its manifest.yaml chooses profiles, branches, and template values
  • ghfanout.yaml — the root config at the top of the config repository: the destination host / org, and tool-wide defaults

Typical tasks map onto this directly:

  • Distribute a file to every repository → put it under base/common/
  • Share a pom.xml among only the Java services → create a java-service profile and list it in the bases: of each Java repository's manifest
  • Give one repository a slightly different value → set it in that overlay's values: and reference it from a template (Templates)

Directory layout

ghfanout.yaml                # Declares the destination host / org (one per config repository)

base/
  common/                    # Common to all repositories, distributed unconditionally
    .gitignore
  java-service/              # A profile (distributed only when selected by an overlay)
    pom.xml.jinja            # .jinja is a template. Variables are expanded and distributed as pom.xml
  python-service/
    pyproject.toml
  node-service/
    package.json

overlays/
  user-service/              # Directory name = destination repository name
    manifest.yaml
  api-gateway/
    manifest.yaml

ghfanout.yaml

The root configuration file:

host: github.example.com   # Defaults to github.com (specify only for GitHub Enterprise Server)
org: myorg             # Required. overlays/<name> corresponds to the myorg/<name> repository
deploy_mode: pr        # Deployment method. pr: open a PR (default) / push: push directly to the target branch
output_dir: dist       # Output directory for build (defaults to dist)
auth:                  # Non-secret GitHub App authentication settings (optional)
  app_id: 123456
  # installation_id: 789   # resolved automatically from org if omitted
  private_key_file: ~/.config/ghfanout/app.pem
Key Required Description
org Destination GitHub org. overlays/<name> corresponds to the <org>/<name> repository
host GitHub host. Defaults to github.com; specify only for GitHub Enterprise Server
deploy_mode pr (default): open a pull request / push: push directly to the target branch. See CLI Reference for the trade-offs
output_dir Output directory for build (defaults to dist). Relative paths resolve against the directory containing ghfanout.yaml
auth Non-secret GitHub App authentication settings. See Authentication

.ghfanoutignore

An optional file at the config repository root that excludes files under base/ from distribution. This lets a profile keep its own README.md or helper files alongside the content it ships, without those files being sent to the target repositories.

# Same syntax as .gitignore ('#' starts a comment only at the start of a line)

# A README.md at any depth in any profile
README.md

# All markdown files
*.md

# Everything under a docs/ directory — but keep docs/keep.md (negation)
docs/
!docs/keep.md
  • Syntax is .gitignore compatible (including ! negation, **, and trailing-slash directory matches). One difference from git itself: ! can re-include a file whose parent directory is excluded, so the docs/ + !docs/keep.md example above works
  • Each pattern is matched against a file's path relative to its profile directory — e.g. base/java-service/docs/README.md is matched as docs/README.md, so one rule applies across common/ and every profile
  • Matching happens on the source name, before the .jinja suffix is stripped. To exclude pom.xml.jinja, write pom.xml.jinja (or *.jinja), not pom.xml
  • Only the single file at the config repository root is read; a .ghfanoutignore placed inside a profile has no effect (it is treated like any other file and distributed)

manifest.yaml

One per overlay, at overlays/<repo>/manifest.yaml:

bases:                 # Profiles to distribute. common/ is always included
  - java-service
branches:              # Destination branches. Defaults to the target repository's default branch only
  - main
  - release-1.x
deploy_mode: push      # Override the deployment method for only this repository (defaults to ghfanout.yaml's value)
values:                # Values referenced from templates (*.jinja) (optional, can be nested)
  version: "1.2.3"
paths:                 # Remap distribution paths (optional, see Path remapping below)
  pom.xml: services/user/pom.xml
excludes:              # Exclude files for this repository (optional, see Exclude files below)
  - internal-notes.md
Key Required Description
bases Profiles (directory names under base/) to distribute. common/ is always included
branches Destination branches. Defaults to the target repository's default branch only. Elements can be strings or objects (see Per-branch overrides)
deploy_mode Overrides ghfanout.yaml's deploy_mode for this repository only
values Values referenced from templates; can be nested. See Templates
paths Remaps distribution paths (source → destination) for this repository. See Path remapping
excludes Excludes files from distribution for this repository (or one branch of it). See Exclude files

Typos are caught early: unknown keys and duplicate branch names are rejected when the manifest is loaded, and a profile listed in bases that has no directory under base/ fails the build.

File precedence

When files with the same relative path exist in multiple profiles, common/ has the lowest priority, and profiles listed later in bases: take precedence. The later one wins and fully overwrites the file — this is reported via an info log when it occurs.

Path remapping (paths)

By default every file is distributed at the same relative path it has inside its profile. The optional paths: mapping in manifest.yaml moves files to a different destination for this repository:

paths:
  pom.xml: services/user/pom.xml       # file entry: moves exactly this file
  workflows/: .github/workflows/       # directory entry: moves every file under workflows/
  • The key (source) is the file's distribution path, after the .jinja suffix is stripped — so templating a file later does not break its remap. The value (destination) is the new distribution path, rendered as a Jinja template with the same variables as file templates (values / repo / org — see Templates)
  • A source and destination that both end in / form a directory entry: every file under the source directory moves to the destination directory with its nested structure preserved, so dozens of files need only one line. Mixing a directory on one side with a file on the other is rejected
  • When several entries match the same file, the exact file entry wins over directory entries, and among directory entries the longest (most specific) prefix wins — so you can remap a whole directory while sending one special file somewhere else
  • Sources and destinations must be relative POSIX paths: absolute paths, . / .. / empty segments, and backslashes are rejected when the manifest is loaded. A destination containing Jinja syntax is validated after rendering instead (a value cannot smuggle in .. or change a file entry into a directory entry)
  • Each file is moved at most once: sources always match the original distribution path, and the result of a remap is never remapped again. Swaps (a: b plus b: a) and chains (a: b plus b: c) therefore work without cascading, for directories as well as files
  • Collisions fail the build: two files ending up at the same destination path, whether from two remaps or from a remap landing on a file that is not itself remapped
  • .ghfanoutignore is unaffected — it matches source names before remapping
  • A source that matches no distributed file in any build variant fails the build, so typos are caught early. If a source matches in some variants but not others (e.g. on a branch that overrides bases), the remap is skipped there and reported in an info log

Because destinations are templates, one entry combined with per-branch values can give each branch a differently named file — without repeating the mapping per branch:

paths:
  deploy.yml: deploy-{{ values.env }}.yml
branches:
  - name: main
    values: {env: prod}      # distributed as deploy-prod.yml
  - name: develop
    values: {env: dev}       # distributed as deploy-dev.yml

Remaps can also be overridden per branch directly — see Per-branch overrides below.

Exclude files (excludes)

.ghfanoutignore excludes files across the entire config repository, for every overlay and every branch. When only one repository — or one branch of one repository — must not receive a file that every other repository does need, use the manifest's excludes: instead:

excludes:
  - internal-notes.md
  - drafts/
  • Syntax and matching point are identical to .ghfanoutignore: .gitignore-compatible patterns (including ! negation and trailing-slash directory matches), matched against each file's path relative to its profile directory, on the source name before the .jinja suffix is stripped
  • excludes: is evaluated independently of .ghfanoutignore — a file is excluded if either one matches, but their ! negations cannot interact with each other
  • Per-branch excludes (see Per-branch overrides) are added to the top-level excludes, not a replacement — a branch's own patterns are appended after the top-level ones, so a branch can write !pattern to re-include something the top level excludes
  • An excluded file never enters the file set to distribute, so it is also unaffected by Path remapping. If a paths: source is excluded in every build variant it would otherwise apply to, the build fails with the same "matched no distributed file" error as a typo'd source

Per-branch overrides

By writing an element of branches: in object form, you can override the profiles to distribute (bases), the template values (values), the path remaps (paths), or the exclude patterns (excludes) for just that branch. Strings and objects can be mixed:

bases:
  - java-service
values:
  version: "1.0"
paths:
  pom.xml: services/user/pom.xml
  ci.yml: .github/workflows/ci.yml
excludes:
  - drafts/
branches:
  - main                    # String = use the top-level bases / values / paths / excludes as-is
  - name: release-1.x       # Object = override bases, values, paths, or excludes for just this branch
    bases:
      - java-service-legacy
    values:
      version: "0.9"
    paths:
      pom.xml: legacy/pom.xml   # Override the destination for this branch
      ci.yml: null              # Remove the inherited remap on this branch
    excludes:
      - legacy/pom.xml          # Added on top of the top-level excludes (drafts/ still applies)

The override semantics differ between the keys:

  • bases is a replacement: the top-level bases is not inherited (explicitly specifying bases: [] distributes only common/)
  • values is a deep merge (similar to Helm): it is recursively merged key by key into the top-level values, inheriting common values while overriding only the differences. Lists are not concatenated — they are replaced wholesale
  • paths is a shallow merge: entries are merged per source path into the top-level paths, and setting a destination to null removes the inherited remap for that source on that branch
  • excludes is a union: the branch's patterns are appended after the top-level excludes, never removing them (see Exclude files)

Note: building an overlay that has a per-branch bases, values, paths, or excludes override writes output per branch to <output>/<branch name>/. See CLI Reference for details.