afon is a Go CLI tool and GitHub Action for continuously applying an upstream
template repository to one or more downstream repositories. Similar to
Cookiecutter and Yeoman for initial scaffolding,
afon goes further: it is designed specifically to be lightweight and to be run
repeatedly on a schedule so that downstream repositories stay aligned with an
evolving template over their entire lifetime - not just at creation time.
There are three components:
- This repository containing the
afonGitHub Action and CLI tool which will do the processing; - An upstream template repository (a Git repository) containing
.tmpl/.ttemplate files, and other static files, that define the shared structure (GitHub Workflows, linter configurations, tooling settings, etc.) across one or more repositories; and - One or more downstream repositories each with a
.afon.yamlconfiguration file and a GitHub Workflow that runs thisafonGitHub Action on a schedule, or on demand, to sync the upstream templates with this downstream repository.
When afon apply runs, it:
- Opens the upstream template repository;
- Walks every file within the configured sub-directory of the upstream repository repository;
- Renders
.tmpl/.tfiles through Go'stext/templateengine with the sprig function library, using the variables set in your.afon.yamlfile; - Writes rendered templates to files, and copies static files, into the output (relative to the root of the downstream repository); and
- Removes output files whose templates render to an empty string.
| Source file | Behaviour |
|---|---|
Any file without .tmpl or .t extension |
Copied verbatim to the output path |
*.tmpl / *.t — renders to non-empty output |
Rendered and written (output path = source path minus the extension) |
*.tmpl / *.t — renders to empty output (whitespace only) |
Output file deleted if it exists; skipped otherwise |
.git/ directory |
Always skipped (never propagated to the downstream repository) |
.afon.yaml |
Always skipped (never propagated to the downstream repository) |
Downstream repositories need a .afon.yaml file at their root:
---
# yamllint disable-line rule:line-length
# yaml-language-server: $schema=https://raw.githubusercontent.com/n3tuk/afon/main/schemas/afon.json
template:
source: https://github.com/your-org/template-repo
path: templates
# branch, tag, or commit SHA (optional)
reference: main
variables:
project_name: my-service
language: go
go_version: '1.26'| Key | Required | Description |
|---|---|---|
template.source |
✓ | Local filesystem path or remote Git URL to the upstream template repository |
template.ref |
Branch, tag, or commit SHA to check out for remote sources | |
template.path |
Subdirectory within the template repository to use as the root. Only files within this path are processed and the prefix is stripped from all output paths. | |
template.token |
Personal access token for private remote repositories. Falls back to the GITHUB_TOKEN environment variable. |
|
variables |
Free-form YAML map. All values are available in templates as {{ .key }}. |
A JSON Schema for .afon.yaml is published alongside each release:
https://raw.githubusercontent.com/n3tuk/afon/main/schemas/afon.json
Add a yaml-language-server comment at the top of .afon.yaml for in-editor
validation in Neovim (with the yaml-language-server) or
Visual Studio Code (with the YAML extension) and other
schema-aware editors:
# yaml-language-server: $schema=https://raw.githubusercontent.com/n3tuk/afon/main/schemas/afon.jsonTemplate files use Go's text/template syntax, augmented by
the sprig function library. All values defined under variables: in
.afon.yaml are available directly by name.
A file named go.mod.tmpl in the upstream template repository:
module github.com/your-org/{{ .project_name }}
go {{ .go_version }}With the example .afon.yaml configuration above, this template renders to
go.mod in the downstream repository to:
module github.com/your-org/my-service
go 1.26| Function | Example |
|---|---|
default |
{{ default "main" .branch }} |
lower / upper |
{{ lower .project_name }} |
title |
{{ title .project_name }} |
replace |
{{ replace "-" "_" .project_name }} |
trimSpace |
{{ trimSpace .value }} |
ternary |
{{ ternary "enabled" "disabled" .feature_flag }} |
toYaml |
{{ toYaml .config | indent 2 }} |
See the sprig documentation for the full function reference.
A template that renders to an empty or whitespace-only string causes the corresponding output file to be deleted (if it exists) or skipped (if it does not). This lets you conditionally exclude files based on variable values:
{{- if .enable_docker -}}
FROM golang:{{ .go_version }}-alpine AS builder
WORKDIR /app
COPY . .
RUN go build -o /app/bin/app ./cmd/app
{{- end -}}If the enable_docker variable is set to false (or the variable is absent) in
the .afon.yaml configuration file, Dockerfile.tmpl renders to an empty
string and Dockerfile is removed from the downstream repository.
afon [global flags] <command> [flags]| Flag | Default | Description |
|---|---|---|
--log-level |
info |
Log level: debug, info, warn, or error. Automatically defaults to debug in GitHub Actions when step debugging is enabled. |
Apply the upstream template to the current directory.
afon apply [flags]| Flag | Short | Default | Description |
|---|---|---|---|
--config |
-c |
.afon.yaml |
Path to the configuration file |
--template |
-t |
Path or URL to the upstream template repository (overrides template.source) |
|
--reference |
-r |
Branch, tag, or commit reference (overrides template.reference) |
|
--path |
-p |
Subdirectory within the template repository (overrides template.path) |
|
--output |
-o |
. |
Output directory |
--token |
Personal access token for private repositories (overrides template.token and GITHUB_TOKEN) |
| Variable | Description |
|---|---|
GITHUB_TOKEN |
Authentication token for cloning private remote template repositories. Used when --token is not set and template.token is empty. |
GITHUB_ACTIONS |
When true (set automatically inside GitHub Actions), combined with ACTIONS_STEP_DEBUG=true to default the log level to debug. |
ACTIONS_STEP_DEBUG |
When true alongside GITHUB_ACTIONS=true, enables debug-level logging automatically. |
Reference the action from any downstream repository workflow:
Caution
When using afon in a GitHub Workflow, ensure that the workflow has write
permissions for contents and pull-requests to allow the action to commit
changes and open pull requests.
Additionally, it is highly recommended that you always pin the full SHA reference for the GitHub Action to ensure supply chain security in production workflows.
jobs:
afon:
permissions:
contents: write
pull-requests: write
steps:
- name: Apply upstream templates with afon
uses: n3tuk/afon@latestThe action reads .afon.yaml from the root of the checked-out workspace. No
additional inputs are required — all configuration is handled through the
configuration file and environment variables.
A ready-to-use example workflow is provided at
examples/workflows/afon.yaml. Copy it to
.github/workflows/afon.yaml in your downstream repository:
name: Repository Template Updates
run-name: Render & Apply Upstream Templates
on:
schedule:
- cron: 0 6 * * 1 # Every Monday at 06:00
workflow_dispatch: # Or on-demand
jobs:
apply:
name: afon
runs-on: ubuntu-latest
permissions:
contents: write
pull-requests: write
steps:
- name: Checkout the repository
uses: actions/checkout@v4
- name: Apply upstream templates with afon
uses: n3tuk/afon@latest
- name: Open a pull request for changes
uses: peter-evans/create-pull-request@v7
with:
title: >-
chore(repository): Apply upstream template changes
body: |-
Automated pull request created by
[afon](https://github.com/n3tuk/afon).
commit-message: >-
chore: Apply upstream template changes
branch: chore/apply-template-updates
delete-branch: trueThe workflow checks out the downstream repository, runs afon, and opens a
pull request for any resulting changes.
See CONTRIBUTING.md for instructions on setting up the development environment, running tests, linting, and submitting pull requests.
- Jonathan Wright (jon@than.io)