diff --git a/README.md b/README.md index 434f3b03d..53e1c92d4 100644 --- a/README.md +++ b/README.md @@ -750,6 +750,7 @@ gate-keeper that reads each change before it lands, are jobs your harness gives ([how to deploy](./documentation/DEPLOY.md)). How to contribute, and the bar for shared code: [documentation/CONTRIBUTING.md](./documentation/CONTRIBUTING.md). +Taking contributions from a contributor's own forge into your GitHub repository: [documentation/contribute/TUTORIAL.md](./documentation/contribute/TUTORIAL.md). Diagram source: [`delegation-model.svg`](./documentation/readme-images/src/delegation-model.svg).
diff --git a/documentation/contribute/TUTORIAL.md b/documentation/contribute/TUTORIAL.md new file mode 100644 index 000000000..daefcd1cb --- /dev/null +++ b/documentation/contribute/TUTORIAL.md @@ -0,0 +1,48 @@ +# Connect a contributor's repo to your GitHub + +*For the repository's GitHub admin. About 10 minutes. The contributor needs no GitHub account, and you keep control of every step.* + +**What you'll have at the end:** a GitHub Action in your repo that, about once an hour, copies the contributor's `release` branch from their own forge into a `contributor` branch of yours. Nothing reaches your main branch until you review and merge it. + +**Before you start:** the contributor sends you the URL of their release repo. Try it on a test repo first if you like; the steps are the same. + +## 1. Add the secret + +**Settings → Secrets and variables → Actions → New repository secret.** +- Name: `UPSTREAM_URL` +- Value: the release-repo URL the contributor sent you + +It's a secret so the URL stays out of your public files. + +## 2. Add the workflow + +Create `.github/workflows/pull-contributor.yml` **on your default branch** with the contents of [`pull-contributor.yml`](pull-contributor.yml). Scheduled workflows run only from the default branch. Read it first: it's about 25 lines, and it: +- fetches the contributor's `release` branch from `UPSTREAM_URL`; +- **stops** if the contributor's commits add, change or delete anything under `.github/`, so nothing from outside can touch a workflow. Your own workflows are fine: it compares their commits with the history you share. If you share none (a fresh release), any `.github/` content is refused; +- updates your `contributor` branch **without force-pushing**, so if their history is ever rewritten, the run fails and waits for you. + +## 3. Run it once + +**Actions → Pull contributor → Run workflow.** When it turns green, you'll see a new `contributor` branch under **Code → Branches**. +If the push step fails with a permission error, check **Settings → Actions → General → Workflow permissions**, or your organization's Actions policy. + +## 4. Protect your main branch + +**Settings → Rules → Rulesets → New branch ruleset**, targeting your default branch: +- **Require a pull request before merging**, with at least one approval; +- **Block force pushes.** + +(Or use **Settings → Branches → Add branch protection rule** with the same two settings.) Don't restrict pushes to `contributor` itself, because the Action has to update it. + +## 5. Review and merge + +**Pull requests → New pull request**, base: your main branch, compare: `contributor`. Review it like any outside contribution, and merge when you're happy. If the pull request stays open, it updates itself each time the Action brings new commits. + +## Good to know + +- **Turn it off:** delete the file, or use **Actions → Pull contributor → ⋯ → Disable workflow**. +- **A red run** means the Action refused something (a change under `.github/`, or a rewritten history). Nothing was changed. Tell the contributor. +- **"Entirely different commit histories":** the release was made fresh, as a new project. Ask the contributor to base it on your main branch, or use it as the start of a new repo. +- **Quiet repos:** GitHub pauses scheduled workflows in a public repo after 60 days with no activity. Re-enable it under **Actions**. +- **Please don't** download the code and re-upload it by hand. It's slower, and it breaks the link to the source. +- **Check the guard yourself:** `bash test-pull-contributor.sh` (next to this file) runs the workflow's exact lines against throwaway local repos, with no network, and shows what it accepts and refuses. diff --git a/documentation/contribute/pull-contributor.yml b/documentation/contribute/pull-contributor.yml new file mode 100644 index 000000000..8d66af1c9 --- /dev/null +++ b/documentation/contribute/pull-contributor.yml @@ -0,0 +1,27 @@ +# .github/workflows/pull-contributor.yml +# Copies the contributor's `release` branch into this repo's `contributor` branch, +# about once an hour. Commit it to your default branch. Delete it to stop. +name: Pull contributor +on: + schedule: [{ cron: '17 * * * *' }] + workflow_dispatch: {} +permissions: { contents: write } +jobs: + sync: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: { fetch-depth: 0 } + - env: { URL: '${{ secrets.UPSTREAM_URL }}' } + run: | + git fetch --no-tags "$URL" release + # refuse any change they make to .github/ + BASE=$(git merge-base HEAD FETCH_HEAD) \ + || BASE=$(git mktree "$t/sync.sh" +export GIT_AUTHOR_NAME=Test GIT_AUTHOR_EMAIL=test@example.invalid +export GIT_COMMITTER_NAME=Test GIT_COMMITTER_EMAIL=test@example.invalid +fails=0 +c() { git -C "$t/$1" "${@:2}" -q; } +sync() { # $1 = receiver bare repo, $2 = contributor bare repo, $3 = clone depth (0 = full) + rm -rf "$t/runner" + if [ "${3:-0}" = 0 ]; then git clone -q "$t/$1" "$t/runner"; else git clone -q --depth "$3" "file://$t/$1" "$t/runner"; fi + (cd "$t/runner" && URL="$t/$2" bash --noprofile --norc -eo pipefail "$t/sync.sh" >/dev/null 2>&1) +} +check() { # $1 = name, $2 = expected (ok|refused), then the sync arguments + local name="$1" want="$2"; shift 2 + local before after got; before=$(git -C "$t/$1" rev-parse -q --verify refs/heads/contributor || echo none) + if sync "$@"; then got=ok; else got=refused; fi + after=$(git -C "$t/$1" rev-parse -q --verify refs/heads/contributor || echo none) + if [ "$got" = refused ] && [ "$before" != "$after" ]; then got="refused BUT the branch moved"; fi + if [ "$got" = "$want" ]; then echo "PASS $name ($got)"; else echo "FAIL $name: wanted $want, got $got"; fails=$((fails+1)); fi +} +commit() { (cd "$t/$1" && git add -A && git commit -qm "$2"); } + +# A receiver whose main already has workflows; the contributor works on top of it. +git init -q --bare -b main "$t/gh.git"; git init -q --bare -b release "$t/up.git" +git init -q -b main "$t/pm"; mkdir -p "$t/pm/.github/workflows" +echo ci > "$t/pm/.github/workflows/ci.yml"; echo m > "$t/pm/m"; commit pm m1; c pm push "$t/gh.git" main +git clone -q "$t/gh.git" "$t/cw"; c cw checkout -b release +echo work > "$t/cw/w"; commit cw w1; c cw push "$t/up.git" release +echo pull > "$t/pm/.github/workflows/pull-contributor.yml"; commit pm add-pull; c pm push "$t/gh.git" main +check "receiver has workflows; release = its main + 1 commit" ok gh.git up.git +echo ci2 > "$t/pm/.github/workflows/ci.yml"; commit pm ci2; c pm push "$t/gh.git" main +echo more >> "$t/cw/w"; commit cw w2; c cw push "$t/up.git" release +check "receiver changed .github/ since; contributor didn't" ok gh.git up.git +good=$(git -C "$t/cw" rev-parse HEAD) +reset() { c cw reset --hard "$good"; c cw push -f "$t/up.git" release; } +echo evil > "$t/cw/.github/workflows/ci.yml"; commit cw edit; c cw push "$t/up.git" release +check "contributor edits a workflow" refused gh.git up.git +reset; echo new > "$t/cw/.github/workflows/new.yml"; commit cw add; c cw push "$t/up.git" release +check "contributor adds a workflow" refused gh.git up.git +reset; rm "$t/cw/.github/workflows/ci.yml"; commit cw delete; c cw push "$t/up.git" release +check "contributor deletes a workflow" refused gh.git up.git +reset +c cw fetch origin; (cd "$t/cw" && git merge -q --no-edit origin/main) +echo m3 >> "$t/cw/w"; commit cw w3; c cw push "$t/up.git" release +check "contributor merges the receiver's main (its .github/ changes come along)" ok gh.git up.git +(cd "$t/cw" && git commit -q --amend -m rewritten); c cw push -f "$t/up.git" release +check "contributor rewrites history (no force-push)" refused gh.git up.git + +# A fresh-history release: no history shared with the receiver. +git init -q --bare -b main "$t/gh2.git"; git init -q --bare -b release "$t/up2.git" +git init -q -b main "$t/pm2"; mkdir -p "$t/pm2/.github/workflows"; echo ci > "$t/pm2/.github/workflows/ci.yml" +commit pm2 init; c pm2 push "$t/gh2.git" main +git init -q -b release "$t/fr"; echo app > "$t/fr/app"; commit fr fresh; c fr push "$t/up2.git" release +check "fresh history without .github/" ok gh2.git up2.git +mkdir -p "$t/fr/.github/workflows"; echo x > "$t/fr/.github/workflows/x.yml"; commit fr add-gh; c fr push "$t/up2.git" release +check "fresh history with .github/ (nothing shared to compare: strict)" refused gh2.git up2.git + +# Fails closed when the checkout is shallow and no shared base can be found. +git init -q --bare -b main "$t/gh3.git"; git init -q --bare -b release "$t/up3.git" +git init -q -b main "$t/pm3"; mkdir -p "$t/pm3/.github/workflows"; echo ci > "$t/pm3/.github/workflows/ci.yml" +commit pm3 m1; echo 2 > "$t/pm3/f2"; commit pm3 m2; echo 3 > "$t/pm3/f3"; commit pm3 m3; c pm3 push "$t/gh3.git" main +git clone -q "$t/gh3.git" "$t/cw3"; c cw3 checkout -b release; c cw3 reset --hard HEAD~2 +echo evil > "$t/cw3/.github/workflows/ci.yml"; commit cw3 edit; c cw3 push "$t/up3.git" release +check "shallow checkout + a workflow edit" refused gh3.git up3.git 1 + +[ "$(git -C "$t/gh.git" rev-list --count main)" = 3 ] && echo "PASS the receiver's main was never touched" || { echo "FAIL main moved"; fails=$((fails+1)); } +echo; [ "$fails" = 0 ] && echo "ALL PASS" || { echo "$fails FAILED"; exit 1; }