Keep a branch on your fork automatically in sync with an upstream repository.
A small, fully auditable GitHub Action: it is a composite action whose
entire behaviour lives in one readable scripts/sync.sh —
no Node runtime, no compiled dist/, no vendored node_modules/ to trust.
- uses: Divinci-AI/sync-fork-action@v1
with:
upstream_repository: octocat/Hello-World
upstream_branch: master
target_branch: mainMost existing fork-sync actions are Node actions that ship a bundled
dist/index.js or a node_modules/ tree — you can't easily see what runs in
your repo with contents: write. This one is deliberately boring:
- One shell script you can read in two minutes. The composite action just
sets environment variables and runs
scripts/sync.sh. - Inputs are passed via
env:, never interpolated into the script body, so an input value can't be parsed as shell (script-injection safe). - Strict input validation on the repository slug and branch names blocks
argument/option injection into git (e.g. a branch named
--upload-pack=…). - Tokens are sent via
http.extraheader, never embedded in a remote URL, so they can't leak through logs orgit remote -v. - No global state. Git identity is set with repo-local config; the runner's
global
~/.gitconfigis never touched.
Add a workflow to your fork (not the upstream). A ready-to-copy file is in
examples/scheduled-sync.yml.
name: Sync fork with upstream
on:
schedule:
- cron: '0 * * * *' # hourly
workflow_dispatch:
permissions:
contents: write # least privilege: only needs to push to your fork
jobs:
sync:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0 # full history is required for merge/rebase
- uses: Divinci-AI/sync-fork-action@v1
with:
upstream_repository: octocat/Hello-World
upstream_branch: master
target_branch: mainRun
actions/checkoutfirst withfetch-depth: 0. This action operates on the repository in the workspace and needs full history to integrate commits.
| Input | Required | Default | Description |
|---|---|---|---|
upstream_repository |
✅ | — | Upstream to sync from: owner/repo (resolved to https://github.com/owner/repo.git) or a full https:// URL. |
upstream_branch |
✅ | — | Branch on the upstream to sync from (e.g. main). |
target_branch |
✅ | — | Branch on your fork to sync into and push (e.g. main). |
github_token |
${{ github.token }} |
Token used to push to your fork (and to read the upstream on github.com when upstream_token is unset). |
|
upstream_token |
'' |
Token used only to fetch a private upstream. Leave empty for public upstreams. | |
sync_mode |
merge |
merge, rebase, or reset — see below. |
|
fetch_args |
'' |
Extra args appended to git fetch upstream (e.g. --tags). Trusted input. |
|
merge_args |
'' |
Extra args for the integration command in merge/rebase mode (e.g. --ff-only). Trusted input. |
|
push_args |
'' |
Extra args appended to git push. Trusted input. |
|
git_user_name |
github-actions[bot] |
Author/committer name for the sync commit. | |
git_user_email |
…github-actions[bot]@users.noreply.github.com |
Author/committer email. | |
dry_run |
false |
When true, report what would sync but don't integrate or push. |
| Output | Description |
|---|---|
has_new_commits |
true when the upstream had commits your target branch was missing. |
commit_count |
Number of commits the target branch was behind upstream. |
upstream_sha |
Resolved commit SHA of the upstream branch tip. |
- uses: Divinci-AI/sync-fork-action@v1
id: sync
with: { upstream_repository: octocat/Hello-World, upstream_branch: master, target_branch: main }
- if: steps.sync.outputs.has_new_commits == 'true'
run: echo "Synced ${{ steps.sync.outputs.commit_count }} commits"| Mode | What it does | When to use |
|---|---|---|
merge (default) |
Merges upstream/<branch> into your target, creating a merge commit when histories diverge. |
You keep your own commits on the fork and want upstream changes merged in. |
rebase |
Replays your target's commits on top of upstream, then force-pushes (--force-with-lease). |
You keep a small, linear set of changes on top of upstream. |
reset |
Hard-resets the target to exactly match upstream, then force-pushes (--force-with-lease). |
An "untouched" mirror fork — discard any divergence and track upstream verbatim. |
Both
rebaseandresetrewrite the target branch's history and therefore force-push (lease-guarded). Only use them on a branch you keep for upstream tracking, not one others push to.mergenever force-pushes.
A common setup: you control a source repo, a customer forks it, and you want every push you make to redeploy their Cloudflare Worker with no manual step. The fork syncs itself (triggered instantly, or on a schedule) and Cloudflare Workers Builds deploys on the resulting push.
See docs/cloudflare-fork-deploy.md for the
full two-sided setup, including the .github/workflows/ push caveat, and
docs/github-pat-setup.md for the click-by-click
guide to creating the GitHub tokens with the right (least-privilege) scopes.
Ready-to-copy workflows are in examples/origin-repo
and examples/customer-fork.
For a private upstream that the default github.token can't read, pass a token
with read access as upstream_token. It is used only for the upstream
fetch; pushes to your fork still use github_token.
- uses: Divinci-AI/sync-fork-action@v1
with:
upstream_repository: my-org/private-upstream
upstream_branch: main
target_branch: main
upstream_token: ${{ secrets.UPSTREAM_READ_TOKEN }}- The
*_argsinputs are passed togitunquoted by design, so you can pass multiple flags. Treat them as trusted workflow configuration — never build them from untrusted data (issue/PR titles, comment bodies, etc.). - Everything else WE put on a git command line (repository, branches) is validated against a strict allowlist and rejected if it could be read as a flag.
- Grant the workflow only
permissions: contents: write.
This action was written from scratch, inspired by the idea behind earlier fork-sync actions but sharing none of their code, specifically so the entire trust surface is a single short shell script you can review yourself.
The whole action is scripts/sync.sh. Tests are plain bash
with no dependencies — they run the real script against throwaway local git
repos.
bash tests/run.sh # unit (validation/injection) + e2e (merge/rebase/reset/dry-run)
shellcheck --enable=all --severity=style scripts/sync.sh
actionlintCI runs all three on every push and PR.
MIT © Divinci AI