Skip to content

Commit b23ff87

Browse files
mikeumusclaude
andcommitted
test+hardening: zero-dep bash test suite, strict lint, PAT docs
- tests/: unit (validation/injection guards) + e2e (merge/no-op/dry-run/reset/rebase/token-masking) running the real script against throwaway local git repos - fix: rebase mode rewrites history -> force-push (lease) like reset (caught by tests) - scripts/sync.sh now passes shellcheck --enable=all --severity=style - CI runs shellcheck (strict) + actionlint + the test suite - docs/github-pat-setup.md: click-by-click least-privilege fine-grained PAT guide - README: dev/test section, sync-mode + token doc links Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 694f953 commit b23ff87

9 files changed

Lines changed: 425 additions & 40 deletions

File tree

.github/workflows/ci.yml

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,11 +14,21 @@ jobs:
1414
steps:
1515
- uses: actions/checkout@v4
1616

17-
- name: ShellCheck
18-
run: shellcheck scripts/sync.sh
17+
- name: ShellCheck — production script (all optional checks)
18+
run: shellcheck --enable=all --severity=style scripts/sync.sh
19+
20+
- name: ShellCheck — test suite
21+
run: shellcheck -x tests/*.sh
1922

2023
- name: Bash syntax check
2124
run: bash -n scripts/sync.sh
2225

2326
- name: actionlint
2427
uses: raven-actions/actionlint@v2
28+
29+
test:
30+
runs-on: ubuntu-latest
31+
steps:
32+
- uses: actions/checkout@v4
33+
- name: Run test suite
34+
run: bash tests/run.sh

README.md

Lines changed: 23 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -102,11 +102,12 @@ jobs:
102102
| Mode | What it does | When to use |
103103
|------|--------------|-------------|
104104
| `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. |
105-
| `rebase` | Replays your target's commits on top of upstream. | You keep a small, linear set of changes on top of upstream. |
105+
| `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. |
106106
| `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. |
107107

108-
> `reset` rewrites your branch history and force-pushes. Only use it on a branch
109-
> you keep pristine for upstream tracking.
108+
> Both `rebase` and `reset` rewrite the target branch's history and therefore
109+
> force-push (lease-guarded). Only use them on a branch you keep for upstream
110+
> tracking, not one others push to. `merge` never force-pushes.
110111

111112
## Recipe: auto-deploy a customer's Cloudflare Worker from a fork
112113

@@ -116,10 +117,11 @@ The fork syncs itself (triggered instantly, or on a schedule) and Cloudflare
116117
Workers Builds deploys on the resulting push.
117118

118119
See **[docs/cloudflare-fork-deploy.md](docs/cloudflare-fork-deploy.md)** for the
119-
full two-sided setup, including least-privilege token scopes and the
120-
`.github/workflows/` push caveat. Ready-to-copy workflows are in
121-
[`examples/origin-repo`](examples/origin-repo) and
122-
[`examples/customer-fork`](examples/customer-fork).
120+
full two-sided setup, including the `.github/workflows/` push caveat, and
121+
**[docs/github-pat-setup.md](docs/github-pat-setup.md)** for the click-by-click
122+
guide to creating the GitHub tokens with the right (least-privilege) scopes.
123+
Ready-to-copy workflows are in [`examples/origin-repo`](examples/origin-repo)
124+
and [`examples/customer-fork`](examples/customer-fork).
123125

124126
## Private upstreams
125127

@@ -150,6 +152,20 @@ This action was written from scratch, inspired by the idea behind earlier
150152
fork-sync actions but sharing none of their code, specifically so the entire
151153
trust surface is a single short shell script you can review yourself.
152154

155+
## Development
156+
157+
The whole action is [`scripts/sync.sh`](scripts/sync.sh). Tests are plain bash
158+
with no dependencies — they run the real script against throwaway local git
159+
repos.
160+
161+
```bash
162+
bash tests/run.sh # unit (validation/injection) + e2e (merge/rebase/reset/dry-run)
163+
shellcheck --enable=all --severity=style scripts/sync.sh
164+
actionlint
165+
```
166+
167+
CI runs all three on every push and PR.
168+
153169
## License
154170

155171
[MIT](LICENSE) © Divinci AI

docs/cloudflare-fork-deploy.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,9 @@ We trigger the fork with `workflow_dispatch` (Actions: write) rather than
5151
`repository_dispatch` (which would require the broader Contents: write) on
5252
purpose — the dispatch token can start a workflow but can touch nothing else.
5353

54+
> **Creating these tokens:** see **[github-pat-setup.md](github-pat-setup.md)**
55+
> for the exact fine-grained PAT steps and per-token permission selections.
56+
5457
### The `.github/workflows/` push caveat
5558

5659
A workflow's default `GITHUB_TOKEN` **cannot push changes to files under

docs/github-pat-setup.md

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
# Creating the GitHub tokens (with the right scopes)
2+
3+
This action moves commits between two repositories that GitHub treats as
4+
independent, so one or two scoped tokens cross the boundary. This page is the
5+
exact, click-by-click recipe for creating them as **fine-grained personal
6+
access tokens** with the **least privilege** that works.
7+
8+
> **Use fine-grained tokens, not classic.** A classic PAT with the `repo` scope
9+
> would work, but it grants read/write to *every* repo the creator can access.
10+
> Fine-grained tokens are scoped to a single repository and a single
11+
> permission, which is what you want here.
12+
13+
## Which token(s) you need
14+
15+
| Token | Needed when | Created by | Lives in | Single permission |
16+
|-------|-------------|------------|----------|-------------------|
17+
| **`FORK_DISPATCH_TOKEN`** | Always (instant-dispatch trigger) | **Customer** | The **upstream** repo's Actions secrets | **Actions: Read and write** on the fork |
18+
| **`UPSTREAM_READ_TOKEN`** | Only if the upstream repo is **private** | **Divinci** | The **fork's** Actions secrets | **Contents: Read-only** on the upstream |
19+
| **`FORK_PUSH_TOKEN`** | Only if the upstream changes files under `.github/workflows/` | **Customer** | The **fork's** Actions secrets | **Contents: Read and write** + **Workflows: Read and write** on the fork |
20+
21+
If you use the polling trigger instead of instant dispatch, you need **none**
22+
of these for a *public* upstream — the fork's built-in `GITHUB_TOKEN` is enough.
23+
24+
---
25+
26+
## Step 1 — Generate a fine-grained PAT
27+
28+
The flow is identical for every token; only the **resource owner**,
29+
**repository**, and **permission** differ (see Step 2).
30+
31+
1. Sign in as the account that should *own* the token (see the table — the
32+
customer for `FORK_DISPATCH_TOKEN`, Divinci for `UPSTREAM_READ_TOKEN`).
33+
2. Go to **GitHub → your avatar → Settings → Developer settings → Personal
34+
access tokens → Fine-grained tokens**
35+
(direct link: <https://github.com/settings/personal-access-tokens/new>).
36+
3. **Token name:** something obvious, e.g. `sync-fork-dispatch (customer X)`.
37+
4. **Resource owner:** if the repo belongs to an organization, pick that org
38+
here (not your personal account). Org-owned tokens may need an org admin to
39+
approve them before they work.
40+
5. **Expiration:** set a real expiry (e.g. 90 days) and calendar a renewal.
41+
Avoid "No expiration".
42+
6. **Repository access → Only select repositories →** choose the **single**
43+
repository this token is for (the fork, or the upstream — see the table).
44+
7. Set the permission from **Step 2**, then **Generate token** and **copy it
45+
now** (you can't see it again).
46+
47+
> **SAML/SSO orgs:** after creating the token, you may need to click
48+
> **Configure SSO → Authorize** next to the token so it can access org repos.
49+
50+
---
51+
52+
## Step 2 — The exact permission per token
53+
54+
Fine-grained tokens default every permission to **No access**. Change only the
55+
one listed below; leave everything else at No access.
56+
57+
### `FORK_DISPATCH_TOKEN` (customer → upstream)
58+
- Repository access: **the customer's fork only**
59+
- **Repository permissions → Actions → Read and write**
60+
61+
That is all it can do: start workflows. It **cannot read or modify code**. We
62+
trigger the fork with `workflow_dispatch` precisely so this token stays this
63+
small. (A `repository_dispatch` trigger would instead require the broader
64+
*Contents: write* — which is why we don't use it.)
65+
66+
### `UPSTREAM_READ_TOKEN` (Divinci → fork) — private upstream only
67+
- Repository access: **the upstream (Divinci) repo only**
68+
- **Repository permissions → Contents → Read-only**
69+
70+
Lets the fork `git fetch` the private upstream. Read, one repo, nothing else.
71+
72+
### `FORK_PUSH_TOKEN` (customer → fork) — only if syncing workflow files
73+
- Repository access: **the customer's fork only**
74+
- **Repository permissions → Contents → Read and write**
75+
- **Repository permissions → Workflows → Read and write**
76+
77+
Only needed because a workflow's built-in `GITHUB_TOKEN` is **not allowed to
78+
push changes to `.github/workflows/`**. If the upstream never changes its
79+
workflow files, you don't need this token.
80+
81+
---
82+
83+
## Step 3 — Store each token as a repository secret
84+
85+
Put each token in the repo named in the table (**not** the repo where it was
86+
created, unless they're the same):
87+
88+
1. Open that repo → **Settings → Secrets and variables → Actions**.
89+
2. **New repository secret**.
90+
3. **Name** it exactly as in the table (`FORK_DISPATCH_TOKEN`, etc.).
91+
4. Paste the token value → **Add secret**.
92+
93+
The workflows reference them as `${{ secrets.FORK_DISPATCH_TOKEN }}` and so on.
94+
95+
---
96+
97+
## Step 4 — Verify
98+
99+
Push a trivial commit to the upstream's default branch and watch:
100+
101+
- Upstream **Actions** tab → `Notify forks` succeeds. If it 404s or says
102+
"Resource not accessible", the `FORK_DISPATCH_TOKEN` is missing the
103+
**Actions: write** permission, points at the wrong repo, or (org) needs SSO
104+
authorization.
105+
- Fork **Actions** tab → `Sync from upstream` runs and pushes.
106+
- A `403`/`could not read Username` during the upstream fetch means the upstream
107+
is private and `UPSTREAM_READ_TOKEN` is missing or under-scoped.
108+
- A push error mentioning `refusing to allow ... workflow` means a workflow file
109+
changed and you need `FORK_PUSH_TOKEN` (see Step 2).
110+
111+
---
112+
113+
## Rotating and revoking
114+
115+
- Tokens expire on the date you set — renew by generating a new one and updating
116+
the secret. Nothing else changes.
117+
- To revoke immediately: the owning account → **Settings → Developer settings →
118+
Fine-grained tokens →** the token → **Delete**. The dependent sync simply
119+
stops working until replaced; nothing is damaged.

scripts/sync.sh

Lines changed: 40 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -32,15 +32,19 @@ log() { echo "==> $*"; }
3232
# Masks the encoded credential so it never appears in logs.
3333
set_github_auth() {
3434
local token="$1"
35-
[ -n "$token" ] || return 0
35+
[[ -n "${token}" ]] || return 0
3636
local encoded
37-
encoded="$(printf 'x-access-token:%s' "$token" | base64 | tr -d '\n')"
37+
encoded="$(printf 'x-access-token:%s' "${token}" | base64 | tr -d '\n')"
3838
echo "::add-mask::${encoded}"
3939
git config --local "http.https://github.com/.extraheader" "AUTHORIZATION: basic ${encoded}"
4040
}
4141

4242
emit_output() { echo "$1=$2" >> "${GITHUB_OUTPUT:-/dev/null}"; }
4343

44+
# Branch names: allow common ref characters, but reject anything starting with
45+
# '-' (option injection) so they cannot be read as git flags.
46+
valid_ref() { [[ "$1" =~ ^[A-Za-z0-9._/-]+$ ]] && [[ "$1" != -* ]]; }
47+
4448
# ----------------------------------------------------------------------------
4549
# Validate and normalize inputs
4650
# ----------------------------------------------------------------------------
@@ -50,30 +54,31 @@ TARGET_BRANCH="${INPUT_TARGET_BRANCH:-}"
5054
SYNC_MODE="${INPUT_SYNC_MODE:-merge}"
5155
DRY_RUN="${INPUT_DRY_RUN:-false}"
5256

53-
[ -n "${INPUT_UPSTREAM_REPOSITORY:-}" ] || die 'Missing required input: upstream_repository'
54-
[ -n "$UPSTREAM_BRANCH" ] || die 'Missing required input: upstream_branch'
55-
[ -n "$TARGET_BRANCH" ] || die 'Missing required input: target_branch'
57+
[[ -n "${INPUT_UPSTREAM_REPOSITORY:-}" ]] || die 'Missing required input: upstream_repository'
58+
[[ -n "${UPSTREAM_BRANCH}" ]] || die 'Missing required input: upstream_branch'
59+
[[ -n "${TARGET_BRANCH}" ]] || die 'Missing required input: target_branch'
5660

57-
# Resolve the upstream URL. Accept "owner/repo" or a full https URL only.
58-
# Patterns are held in variables to avoid shell-quoting surprises, and the URL
59-
# set is deliberately conservative (no shell metacharacters allowed).
60-
url_re='^https://[A-Za-z0-9._~:/?#@%-]+$'
61+
# Resolve the upstream URL. Accept "owner/repo", a full https URL, or a file://
62+
# URL (the last is used by the test suite to sync from a local repo). Patterns
63+
# are held in variables to avoid shell-quoting surprises, and the URL set is
64+
# deliberately conservative — no shell metacharacters are allowed.
65+
url_re='^(https|file)://[A-Za-z0-9._~:/?#@%-]+$'
6166
slug_re='^[A-Za-z0-9._-]+/[A-Za-z0-9._-]+$'
62-
if [[ "$INPUT_UPSTREAM_REPOSITORY" =~ $url_re ]]; then
63-
UPSTREAM_URL="$INPUT_UPSTREAM_REPOSITORY"
64-
elif [[ "$INPUT_UPSTREAM_REPOSITORY" =~ $slug_re ]]; then
67+
if [[ "${INPUT_UPSTREAM_REPOSITORY}" =~ ${url_re} ]]; then
68+
UPSTREAM_URL="${INPUT_UPSTREAM_REPOSITORY}"
69+
elif [[ "${INPUT_UPSTREAM_REPOSITORY}" =~ ${slug_re} ]]; then
6570
UPSTREAM_URL="https://github.com/${INPUT_UPSTREAM_REPOSITORY}.git"
6671
else
6772
die "Invalid upstream_repository: '${INPUT_UPSTREAM_REPOSITORY}'. Use 'owner/repo' or a full https URL."
6873
fi
6974

70-
# Branch names: allow common ref characters, but reject anything starting with
71-
# '-' (option injection) so they cannot be read as git flags.
72-
valid_ref() { [[ "$1" =~ ^[A-Za-z0-9._/-]+$ ]] && [[ "$1" != -* ]]; }
73-
valid_ref "$UPSTREAM_BRANCH" || die "Invalid upstream_branch: '${UPSTREAM_BRANCH}'"
74-
valid_ref "$TARGET_BRANCH" || die "Invalid target_branch: '${TARGET_BRANCH}'"
75+
# valid_ref is a pure predicate meant for use in conditionals (SC2310 N/A).
76+
# shellcheck disable=SC2310
77+
if ! valid_ref "${UPSTREAM_BRANCH}"; then die "Invalid upstream_branch: '${UPSTREAM_BRANCH}'"; fi
78+
# shellcheck disable=SC2310
79+
if ! valid_ref "${TARGET_BRANCH}"; then die "Invalid target_branch: '${TARGET_BRANCH}'"; fi
7580

76-
case "$SYNC_MODE" in
81+
case "${SYNC_MODE}" in
7782
merge|rebase|reset) ;;
7883
*) die "Invalid sync_mode: '${SYNC_MODE}'. Expected merge, rebase, or reset." ;;
7984
esac
@@ -100,25 +105,25 @@ set_github_auth "${INPUT_GITHUB_TOKEN:-}"
100105

101106
log "Adding upstream remote: ${UPSTREAM_URL}"
102107
git remote remove upstream >/dev/null 2>&1 || true
103-
git remote add upstream "$UPSTREAM_URL"
108+
git remote add upstream "${UPSTREAM_URL}"
104109

105110
log "Fetching origin/${TARGET_BRANCH}"
106-
git fetch --no-tags origin "$TARGET_BRANCH"
111+
git fetch --no-tags origin "${TARGET_BRANCH}"
107112

108113
log "Fetching upstream/${UPSTREAM_BRANCH}"
109114
# If an upstream_token is set, use it just for this fetch, then restore the
110115
# push token. Single auth header at all times — no double-Authorization.
111-
if [ -n "${INPUT_UPSTREAM_TOKEN:-}" ]; then
116+
if [[ -n "${INPUT_UPSTREAM_TOKEN:-}" ]]; then
112117
set_github_auth "${INPUT_UPSTREAM_TOKEN}"
113118
fi
114119
# shellcheck disable=SC2086 # intentional word-splitting of trusted fetch_args
115-
git fetch --no-tags ${INPUT_FETCH_ARGS:-} upstream "$UPSTREAM_BRANCH"
116-
if [ -n "${INPUT_UPSTREAM_TOKEN:-}" ]; then
120+
git fetch --no-tags ${INPUT_FETCH_ARGS:-} upstream "${UPSTREAM_BRANCH}"
121+
if [[ -n "${INPUT_UPSTREAM_TOKEN:-}" ]]; then
117122
set_github_auth "${INPUT_GITHUB_TOKEN:-}"
118123
fi
119124

120125
# Reset our local target branch to match origin exactly, as the base to sync.
121-
git checkout -B "$TARGET_BRANCH" "origin/${TARGET_BRANCH}"
126+
git checkout -B "${TARGET_BRANCH}" "origin/${TARGET_BRANCH}"
122127

123128
# ----------------------------------------------------------------------------
124129
# Determine whether there is anything to sync
@@ -127,10 +132,10 @@ git checkout -B "$TARGET_BRANCH" "origin/${TARGET_BRANCH}"
127132
UPSTREAM_SHA="$(git rev-parse "upstream/${UPSTREAM_BRANCH}")"
128133
COMMIT_COUNT="$(git rev-list --count "HEAD..upstream/${UPSTREAM_BRANCH}")"
129134

130-
emit_output upstream_sha "$UPSTREAM_SHA"
131-
emit_output commit_count "$COMMIT_COUNT"
135+
emit_output upstream_sha "${UPSTREAM_SHA}"
136+
emit_output commit_count "${COMMIT_COUNT}"
132137

133-
if [ "$COMMIT_COUNT" -eq 0 ]; then
138+
if [[ "${COMMIT_COUNT}" -eq 0 ]]; then
134139
log "Already up to date with upstream/${UPSTREAM_BRANCH}. Nothing to sync."
135140
emit_output has_new_commits false
136141
{
@@ -148,7 +153,7 @@ git --no-pager log --oneline "HEAD..upstream/${UPSTREAM_BRANCH}" | sed 's/^/
148153
# Dry run stops here
149154
# ----------------------------------------------------------------------------
150155

151-
if [ "$DRY_RUN" = "true" ]; then
156+
if [[ "${DRY_RUN}" == "true" ]]; then
152157
log "dry_run=true — not integrating or pushing."
153158
{
154159
echo "### Sync Fork Action (dry run)"
@@ -162,7 +167,7 @@ fi
162167
# ----------------------------------------------------------------------------
163168

164169
log "Integrating with sync_mode='${SYNC_MODE}'"
165-
case "$SYNC_MODE" in
170+
case "${SYNC_MODE}" in
166171
merge)
167172
# shellcheck disable=SC2086 # intentional word-splitting of trusted merge_args
168173
git merge --no-edit ${INPUT_MERGE_ARGS:-} "upstream/${UPSTREAM_BRANCH}"
@@ -174,15 +179,19 @@ case "$SYNC_MODE" in
174179
reset)
175180
git reset --hard "upstream/${UPSTREAM_BRANCH}"
176181
;;
182+
*)
183+
die "Unreachable: unvalidated sync_mode '${SYNC_MODE}'"
184+
;;
177185
esac
178186

179187
# ----------------------------------------------------------------------------
180188
# Push
181189
# ----------------------------------------------------------------------------
182190

183191
PUSH_ARGS="${INPUT_PUSH_ARGS:-}"
184-
# reset mode rewrites history; force-push unless the caller already specified one.
185-
if [ "$SYNC_MODE" = "reset" ] && [[ "$PUSH_ARGS" != *--force* ]]; then
192+
# reset and rebase rewrite the target branch's history, so the push back is not
193+
# a fast-forward — add a (lease-guarded) force unless the caller specified one.
194+
if [[ "${SYNC_MODE}" == "reset" || "${SYNC_MODE}" == "rebase" ]] && [[ "${PUSH_ARGS}" != *--force* ]]; then
186195
PUSH_ARGS="--force-with-lease ${PUSH_ARGS}"
187196
fi
188197

0 commit comments

Comments
 (0)