A GitHub Action that keeps your project boilerplate files in sync with another repository.
- 🔄 Automatic syncing - Keep project files up-to-date with boilerplate sources
- 🆕 Create missing files - Optionally create new files when boilerplate adds them
- 📊 Detailed reports - Step summary shows what changed, what was skipped, and what failed
- 🔐 Private repo support - Use separate tokens for source repositories
- 🔧 Composable - Pairs with
peter-evans/create-pull-requestfor PR creation - 📁 Glob patterns - Sync multiple files with patterns like
*.mdor**/*.yml - 🪝 Lifecycle hooks - Check the workspace before a sync, keep local changes to synced files with transforms, and run formatters or tests afterwards
Syncing .github/workflows/*.y{,a}ml requires a token with permission to update workflow files for the commit and PR steps. See the App-backed template sync setup.
In this repository, scheduled boilerplate sync workflows use reserved head branch prefixes—chore/sync-boilerplate/<run_id> for non-workflow files and chore/sync-boilerplate-workflows/<run_id> for workflow copies—so automation can supersede older open PRs in the same stream. Do not use those prefixes for manual branches.
This project was inspired by kbrashears5/github-action-file-sync, which syncs files across repositories using a push model — the source repository pushes files to target repositories.
Boilerplate Sync takes the opposite approach with a pull model:
| Aspect | Push Model (file-sync) | Pull Model (boilerplate-sync) |
|---|---|---|
| Direction | Source pushes to targets | Targets pull from source |
| Control | Source repo decides what to sync | Each target repo decides what to sync |
| Access | Source needs write access to all targets | Targets only need read access to source |
| Configuration | Centralized in source repo | Distributed in each target repo |
| Review | Changes applied directly or via PR from source | Changes go through PR review in target repo |
The pull model is ideal when:
- Target repos want full control over what they sync and when
- You don't want a central repo with write access to many repositories
The push model is ideal when:
- You want centralized control over what all repos should have
- You need to enforce consistency across many repositories at once
This action is designed as a simpler alternative to templating tools for targeted sets of stable files. It is not intended to be a comprehensive solution for all boilerplate needs. Be aware that it can introduce hidden or circular dependencies and may pose security risks, especially when source repositories are untrusted.
The action uses the GitHub Actions Node.js 24 runtime. Self-hosted runners must support that runtime. Hooks also need bash and any tools their commands invoke (for example, jq).
name: Sync Boilerplate
on:
schedule:
- cron: '0 9 * * 1' # Every Monday at 09:00 UTC
workflow_dispatch: # Manual trigger
jobs:
sync:
runs-on: ubuntu-latest
permissions:
contents: write
pull-requests: write
steps:
- uses: actions/checkout@v7
- name: Sync boilerplate files
uses: michen00/boilerplate-sync@v1
id: sync
with:
sources: |
- source: my-org/boilerplate
ref: main
default_files:
- eslint.config.mjs
- .prettierrc
file_pairs:
- local_path: .github/PULL_REQUEST_TEMPLATE.md
source_path: templates/pull-request.md
github-token: ${{ secrets.GITHUB_TOKEN }}
- name: Create Pull Request
if: steps.sync.outputs.has-changes == 'true'
uses: peter-evans/create-pull-request@v8
with:
branch: boilerplate-sync/${{ github.run_id }}
delete-branch: true
title: 'chore: sync boilerplate files'
body: |
## Boilerplate Sync
Updated: ${{ steps.sync.outputs.updated-count }}
Skipped: ${{ steps.sync.outputs.skipped-count }}
Failed: ${{ steps.sync.outputs.failed-count }}
labels: |
boilerplate
automated| Input | Required | Default | Description |
|---|---|---|---|
sources |
✅ | - | YAML array of source repositories (see below) |
github-token |
✅ | ${{ github.token }} |
Token for accessing source repos |
create-missing |
❌ | true |
Create project files that don't exist yet |
fail-on-error |
❌ | false |
Fail the action if any file sync fails |
hooks |
❌ | - | YAML mapping of lifecycle hooks (see Hooks) |
The sources array groups files by their source repository. Each source contains:
| Field | Required | Description |
|---|---|---|
source |
✅ | Source repository in owner/repo format |
ref |
❌ | Git ref (branch, tag, SHA) - applies to all files from this source. Defaults to the source repo's default branch |
source-token |
❌ | Token for private source repos (falls back to github-token) |
default_files |
❌* | List of files where local path equals source path. Supports glob patterns (see below) |
file_pairs |
❌* | Array of file mappings with explicit paths (see below) |
*At least one of default_files or file_pairs is required per source.
Each file mapping in file_pairs:
| Field | Required | Description |
|---|---|---|
local_path |
✅ | Path in your repository to update |
source_path |
❌ | Path to the file in the source repository. Defaults to local_path if not specified |
Glob patterns are supported only in default_files; each file_pairs entry selects one source file.
sources: |
# Sync files with same paths in both repos
- source: my-org/boilerplate
ref: main
default_files:
- eslint.config.mjs
- .prettierrc
# Sync files with different paths
- source: my-org/boilerplate
ref: v2.0.0
file_pairs:
- local_path: .github/workflows/ci.yml
source_path: workflows/ci.yml
- local_path: tsconfig.json
source_path: configs/tsconfig.strict.json
# Mix both formats + private repo with custom token
- source: my-org/private-templates
source-token: ${{ secrets.PRIVATE_PAT }}
default_files:
- config.json
file_pairs:
- local_path: .env.example
source_path: templates/.env.exampleThe default_files field supports glob patterns for syncing multiple files at once:
| Pattern | Description | Example |
|---|---|---|
* |
Match any characters except / |
*.md matches README.md |
** |
Match any characters including / |
**/*.ts matches nested .ts |
? |
Match single character | file?.ts matches file1.ts |
[abc] |
Match character class | [abc].ts matches a.ts |
{a,b} |
Match alternatives | *.{js,ts} matches both |
sources: |
- source: my-org/boilerplate
default_files:
- .eslintrc.js # Exact file
- .github/ISSUE_TEMPLATE/*.md # All .md files in directory
- .github/workflows/*.y{,a}ml # All workflow YAML files
- configs/**/*.json # Recursive JSON filesWhen a glob pattern matches multiple files, each matched file is synced with local_path === source_path.
Lifecycle hooks require v1.1.0 or later. The optional hooks input runs your own commands at three points in a sync. Like sources, it is YAML. This example applies a local JSON overlay and rewrites documentation without running code from the synced files:
- uses: actions/checkout@v7
with:
persist-credentials: false
- name: Sync with local customizations
uses: michen00/boilerplate-sync@v1
with:
sources: |
- source: my-org/boilerplate
default_files:
- package.json
- docs/*.md
hooks: |
pre-sync: test -f .github/package.overrides.json
transform:
package.json: jq -s '.[0] * .[1]' - .github/package.overrides.json
'docs/*.md': sed 's/upstream-org/our-org/g'
post-sync: git diff --statCommit .github/package.overrides.json in the consuming repository and keep it outside the synced file list. To run formatters or tests after syncing, see Hook security first.
| Key | Value | When it runs |
|---|---|---|
pre-sync |
A command, or a list of commands | Once, after globs are expanded and every hook is checked, and before any file is fetched |
transform |
A map of local path or glob to one command | For each matching file, after the file is fetched and before it is compared with the local file |
post-sync |
A command, or a list of commands | Once, after every file has been processed |
Each command runs in bash from the workspace root, the way a run: step with shell: bash runs it: bash --noprofile --norc -e -o pipefail. On Windows the action uses Git for Windows bash, which GitHub-hosted runners include. pre-sync and post-sync get an empty stdin. A list runs in order and stops at the first command that fails. Quote a key that starts with *, as in '*.md', because YAML reads a bare * as an alias.
A transform rewrites a synced file before the action decides whether the file changed:
- The action fetches the file from its source.
- The transform command reads the fetched content on stdin and prints the content to keep on stdout.
- The action compares that output with the local file. If they match, the file is skipped; otherwise the action writes the output.
This lets a repository keep its own changes to a synced file and still receive upstream updates. Once the transformed file is committed, later runs skip it until upstream changes.
A transform:
- runs on every fetch of its file, so it must be deterministic and idempotent: the same input must always give the same output;
- can read the current local file at
$BOILERPLATE_SYNC_LOCAL_PATH, which has not been overwritten yet; for a file that does not exist yet,$BOILERPLATE_SYNC_FILE_EXISTSisfalse; - fails when it exits non-zero or prints nothing. The file is then reported as failed and left untouched, and
fail-on-errordecides what happens next.
Keys match files by local path, either exactly or as a glob matched the same way as default_files globs. A file can match only one key. An exact key that no source syncs is a configuration error, which catches typos.
| Variable | Set for | Value |
|---|---|---|
BOILERPLATE_SYNC_HOOK |
All hooks | pre-sync, transform or post-sync |
BOILERPLATE_SYNC_LOCAL_PATH |
transform |
Path of the file in this repository |
BOILERPLATE_SYNC_SOURCE |
transform |
Source repository, as owner/repo |
BOILERPLATE_SYNC_SOURCE_PATH |
transform |
Path of the file in the source repository |
BOILERPLATE_SYNC_REF |
transform |
The configured ref, or empty |
BOILERPLATE_SYNC_RESOLVED_REF |
transform |
The ref the file was fetched at |
BOILERPLATE_SYNC_FILE_EXISTS |
transform |
true or false |
BOILERPLATE_SYNC_HAS_CHANGES |
post-sync |
true or false, so that post-sync can skip work when nothing changed |
pre-syncfails: the action fails before fetching any file. No file is written and no output is set.- A transform fails: the file is reported as failed and left untouched (see Transforms).
post-syncfails: the action fails, whateverfail-on-erroris set to. The step summary and outputs are written first, so a later step withif: always(), or a step markedcontinue-on-error, can still use them.post-syncdoes not run whenfail-on-erroristrueand a file failed.
has-changes and the counts describe synced files after transforms. They do not include changes that post-sync makes, such as files a formatter rewrites. To detect those, run git status --porcelain in a later step.
Hooks run arbitrary commands, so only the workflow that uses this action can configure them; synced content cannot. The action also:
- removes every
INPUT_*andACTIONS_*variable, andGITHUB_OUTPUTandGITHUB_STATE, from each hook's environment, so no hook receivesgithub-token, anysource-tokenor the runner's own tokens through it; - rejects a hook command that names a file this action syncs, because the source repository would then control code that runs in this step. With
scripts/merge.shsynced, that rejectsbash scripts/merge.sh, an unquoted glob that expands to it such asbash scripts/*.sh, and the same commands inside a command substitution ($(...)or backticks, quoted or not), abash -cstring or aneval. Paths are compared ignoring case, with\treated as a separator, because Windows and default macOS runners resolve them that way. The check runs against every synced path, glob-expanded ones included, before any hook runs,pre-syncincluded. When hooks are set, adefault_filesglob that fails to expand or matches no file stops the action before any hook runs, because the hooks cannot be checked against the files it syncs.
The path check also inspects a shell's +c command string, including option bundles such as +ec. In ANSI-C quotes ($'...'), it decodes named escapes such as \n and \t, and escaped quotes and backslashes. Numeric escapes (\x, octal, \u, \U), control escapes (\c) and other unsupported escapes are configuration errors; use literal characters or ordinary quotes instead.
The second check covers what a command names, not what it runs indirectly. It does not resolve variables, and a command can still run synced code through another program: npm test runs scripts from a synced package.json, and make reads a synced Makefile. That code runs before anyone reviews the pull request. When synced files define your tests, run the tests in the pull request's CI instead of in post-sync. Never put a token in the step's env: when you use hooks, because hooks inherit it.
The action controls only the hook's environment, not the files it can access. By default actions/checkout persists authentication credentials for subsequent Git commands; current versions store them under $RUNNER_TEMP. Hooks share access to those files. When you use hooks, set persist-credentials: false on the checkout step, and give any later step that pushes its own token:
- uses: actions/checkout@v7
with:
persist-credentials: falseAfter file processing completes, the action writes the step summary and sets these outputs before running post-sync. Configuration errors and a failing pre-sync produce no outputs.
| Output | Type | Description |
|---|---|---|
has-changes |
string |
"true" if any files were updated or created, "false" otherwise. |
updated-count |
string |
Number of files updated or created. |
failed-count |
string |
Number of files that failed to sync. |
skipped-count |
string |
Number of files skipped (unchanged, or missing when create-missing is disabled). |
summary |
string |
JSON-encoded sync summary with details on each file. Per-source tokens are excluded. |
This repository includes real, working example workflows that sync multiple files from a template repository for Python projects:
.github/workflows/sync-template-non-workflow.yml— syncs non-workflow files..github/workflows/sync-template-github-workflows.yml— syncs.github/workflows/*files (requires additional permissions, as noted above).
You can copy a workflow file and adapt it for your own needs. Simply modify the sources configuration to point to your template repository and add or remove files as needed. Note that these workflows also include repo-local steps (for example, the ./.github/actions/supersede-boilerplate-prs action) that do not exist outside this repository — remove or replace those steps when adapting a workflow for your own project.
Sync a few config files on a weekly schedule:
name: Sync Boilerplate
on:
schedule:
- cron: '0 9 * * 1' # Every Monday at 09:00 UTC
workflow_dispatch: # Manual trigger
jobs:
sync:
runs-on: ubuntu-latest
permissions:
contents: write
pull-requests: write
steps:
- uses: actions/checkout@v7
- name: Sync boilerplate files
uses: michen00/boilerplate-sync@v1
id: sync
with:
sources: |
- source: my-org/boilerplate
default_files:
- eslint.config.mjs # local and source paths are the same
- .github/ISSUE_TEMPLATE/*.md # glob patterns are supported
file_pairs: # glob patterns are NOT supported
- local_path: .github/PULL_REQUEST_TEMPLATE.md
source_path: templates/pull-request.md
github-token: ${{ secrets.GITHUB_TOKEN }}
- name: Create pull request
uses: peter-evans/create-pull-request@v8
with:
branch: boilerplate-sync/${{ github.run_id }}
title: 'chore: sync boilerplate files'This repository's two template-sync workflows reuse vars.APP_CLIENT_ID and secrets.APP_PRIVATE_KEY, the same credentials referenced by the release workflow. The App must be installed on the destination repository with Contents: write and Pull requests: write. The workflow-file stream also requests Workflows: write; approve that permission on the installation if needed. Token creation fails if the App lacks the requested permissions.
Both workflows run only from the default branch. Non-workflow files sync weekly or on manual dispatch; workflow files sync on manual dispatch. Changes become signed App-authored pull requests, which can trigger normal PR checks. Runs with no changes skip PR creation. The public template is read using GITHUB_TOKEN, so the App token only needs access to this destination repository.
The selection was compared against template commit 4184670 on 2026-11-03. These 13 files have equivalent configuration and behavior here:
| Stream | Selected files |
|---|---|
| Configs | .editorconfig, .gitlint, .markdownlint.yml, .prettierrc, tombi.toml, .github/actionlint.yaml |
| Supporting automation | .github/actions/detect-bot-author/action.yml, .github/scripts/approve-bot-gated-runs.sh |
| Workflows | bot-automerge-disarm.yml, freeze-pr-links.yml, greet-new-contributors.yml, pr-body-unwrap.yml, pr-catchup.yml |
The greeting transform preserves the boilerplate-sync community name. Other differences are comments or equivalent JSON/YAML formatting. The supporting action and script are included because the greeting and catch-up workflows depend on them. Future runs follow the template's default branch and propose updates through PRs.
Project-specific CI, release, auto-merge, PR checks, editor settings, and lint/ignore configurations remain local. The previous workflow selection included an upstream file that no longer exists (pre-commit-autoupdate.yml), a linter with different triggers (lint-github-actions.yml), and branch deletion with different protection error handling (delete-bot-branches-for-closed-prs.yml); these are excluded from sync, while their local files remain in place.
To expand the selection, edit sources in the corresponding workflow:
- Add same-path files or globs to
default_files, such as.github/ISSUE_TEMPLATE/*.mdordocs/**/*.md. - Use
file_pairswhen the source and destination paths differ. - Put
.github/workflows/files insync-template-github-workflows.ymlso commits use a token with workflow-write permission.
Select files appropriate for the destination project: michen00/template includes Python-specific configuration that may not apply here. Files are replaced with upstream content; use transforms when local customization is needed. The action currently handles UTF-8 text, not arbitrary binary files.
The existing App token check workflow verifies token creation and repository access. A successful sync run is still needed to verify workflow-write permission and PR creation end to end.
Use a PAT to access private boilerplate repos by specifying source-token per source:
- uses: michen00/boilerplate-sync@v1
with:
sources: |
- source: my-org/private-boilerplate
source-token: ${{ secrets.BOILERPLATE_PAT }}
default_files:
- .github/ISSUE_TEMPLATE/bug_report.md
- source: my-org/public-boilerplate
# No source-token needed - uses github-token
default_files:
- .*.toml
github-token: ${{ secrets.GITHUB_TOKEN }}Fail the workflow if any file fails to sync:
- uses: michen00/boilerplate-sync@v1
with:
sources: |
- source: my-org/boilerplate
default_files:
- .*.yml
github-token: ${{ secrets.GITHUB_TOKEN }}
fail-on-error: trueOnly update files that already exist:
- uses: michen00/boilerplate-sync@v1
with:
sources: |
- source: my-org/boilerplate
file_pairs:
- local_path: .github/PULL_REQUEST_TEMPLATE.md
source_path: templates/pull-request.md
github-token: ${{ secrets.GITHUB_TOKEN }}
create-missing: false- name: Sync boilerplate files
uses: michen00/boilerplate-sync@v1
id: sync
with:
sources: |
- source: my-org/boilerplate
default_files:
- .reusable-config.yml
github-token: ${{ secrets.GITHUB_TOKEN }}
- name: Create pull request
id: cpr
if: steps.sync.outputs.has-changes == 'true'
uses: peter-evans/create-pull-request@v8
with:
branch: boilerplate-sync/${{ github.run_id }}
title: 'chore: sync boilerplate files'
body: |
## Boilerplate Sync
Updated: ${{ steps.sync.outputs.updated-count }}
Skipped: ${{ steps.sync.outputs.skipped-count }}
Failed: ${{ steps.sync.outputs.failed-count }}
- name: Log PR URL
if: steps.sync.outputs.has-changes == 'true'
run: echo "PR created at ${{ steps.cpr.outputs.pull-request-url }}"- Parse Configuration - Validates the
sourcesandhooksinputs - Expand and Check Paths - Lists source files for globs through the GitHub API and checks hook references and transform assignments
- Run
pre-sync- Runs thepre-synchook, if configured, before any file content is fetched - Fetch Source Files - Downloads each file from its source repository using the GitHub API
- Transform - Runs the matching
transformhook, if any, on each fetched file - Update or Create - Updates or creates each file whose content differs from the workspace
- Output Results - Sets outputs (
has-changes, counts, summary) for use by subsequent steps - Run
post-sync- Runs thepost-synchook, if configured
The action writes files directly to the workspace. Use peter-evans/create-pull-request or similar to create a PR from the changes.
When using with peter-evans/create-pull-request, your workflow needs these permissions:
permissions:
contents: write # To push branches to the target repository
pull-requests: write # To create PRs (for peter-evans/create-pull-request)For a private source repository, use a source-token with access to that repository. A fine-grained PAT or GitHub App token needs Contents: read; a classic PAT needs repo scope. The workflow's default GITHUB_TOKEN is scoped to the consuming repository, so use a separate source token to read another private repository.
Release tags (vX.Y.Z) are protected by a tag ruleset covering refs/tags/v*.*.*: update and deletion are blocked, so once published they are immutable. They are annotated but unsigned. The v1 alias is deliberately outside that ruleset and movable (it tracks the latest v1.x release) and is created by the release automation, not by hand. Integrity for the consumed artifact comes from SLSA build provenance, verifiable with gh attestation verify dist/index.js --repo michen00/boilerplate-sync. Pin the action to a full commit SHA if your threat model includes compromise of this repository:
- uses: michen00/boilerplate-sync@0e9141c0e4cba19be30565e78b0afdb29e470635 # v1.1.0Report vulnerabilities privately via GitHub security advisories — see SECURITY.md. This is a personal project maintained on a best-effort basis.
- Only supports GitHub repositories as sources (other sources are planned for future versions depending on user interest)
- Files are replaced entirely, unless a
transformhook rewrites them - No dependency analysis - The action does not understand relationships between files or detect when syncing one file requires changes to other files
- No context awareness - Project-specific customizations may be overwritten without warning
Do not use this action for critical files.
This action performs direct file replacement without understanding:
- Dependencies between files
- Required configuration changes in other files
- Breaking changes that might affect your project
- Context-specific customizations your project may need
Use with caution.
Key risks to consider:
- Hidden dependencies - Synced files may depend on other files or configurations that aren't explicitly synced, leading to broken or incomplete setups
- Circular references - Source repositories that sync from each other can create circular dependencies, causing infinite loops or unexpected behavior
- Supply chain risks - Syncing from untrusted or compromised source repositories can introduce broken or malicious code into your project
Recommended use cases:
- Non-critical configuration files (e.g.,
eslint.config.mjs,.prettierrc) - Workflow files that are truly boilerplate
- Documentation templates
- Shared tooling configurations
Not recommended for:
- Build configuration files (e.g.,
package.json,tsconfig.json) that may have project-specific dependencies - Deployment configurations
- Environment-specific settings
- Files that require coordination with other files
- Any file where changes could break your build or deployment
- CONTRIBUTING.md — asking a question, reporting a bug, suggesting an enhancement, and making a first code contribution
- SECURITY.md — supported versions, reporting a vulnerability privately, and guidance for consumers
- CHANGELOG.md — release history