From 1bc7d612949d2d905c9027c1d714f4bf5ecfdaf4 Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Sun, 24 May 2026 17:11:31 -0400 Subject: [PATCH] =?UTF-8?q?chore:=20introduce=20`next`=20integration=20bra?= =?UTF-8?q?nch=20(Phase=201=20=E2=80=94=20additive)=20(#231)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds: - docs/branching.md — beginner contributor guide - docs/adr/XXXX-...md — ADR (will be renamed with issue#) - .github/workflows/auto-backmerge.yml — disabled in Phase 1 - .github/workflows/pr-target-validator.yml — warn-only in Phase 1 - scripts/setup-branch-protection.sh — idempotent gh api script Modifies: - .github/workflows/branch-naming.yml — recognize 'next' - CONTRIBUTING.md — 'Where Do I Open My PR?' section Phase 1 is additive: nothing operational changes until Phase 2 flips auto-backmerge.yml's if:false→true, flips pr-target-validator.yml's WARN_ONLY→false, creates the next branch, and switches the default branch. See the ADR for the migration plan. --- .github/workflows/auto-backmerge.yml | 143 +++++ .github/workflows/branch-naming.yml | 2 +- .github/workflows/pr-target-validator.yml | 123 ++++ CONTRIBUTING.md | 40 ++ .../230-introduce-next-integration-branch.md | 346 +++++++++++ docs/branching.md | 272 +++++++++ next-branch-files.tar.gz | Bin 0 -> 14810 bytes rollout-next-phase1.sh | 546 ++++++++++++++++++ rollout-next-phase2.sh | 300 ++++++++++ scripts/setup-branch-protection.sh | 236 ++++++++ 10 files changed, 2007 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/auto-backmerge.yml create mode 100644 .github/workflows/pr-target-validator.yml create mode 100644 docs/adr/230-introduce-next-integration-branch.md create mode 100644 docs/branching.md create mode 100644 next-branch-files.tar.gz create mode 100755 rollout-next-phase1.sh create mode 100755 rollout-next-phase2.sh create mode 100755 scripts/setup-branch-protection.sh diff --git a/.github/workflows/auto-backmerge.yml b/.github/workflows/auto-backmerge.yml new file mode 100644 index 000000000..dbb1dec0b --- /dev/null +++ b/.github/workflows/auto-backmerge.yml @@ -0,0 +1,143 @@ +name: Auto Back-Merge main → next + +# Keep `next` at-or-ahead of `main` automatically. Every push to `main` +# (release finalize, hotfix finalize, occasional emergency fix) opens — or +# updates — a PR titled "chore: back-merge main → next". CI gates the merge; +# auto-merge is enabled so it lands as soon as it's green. +# +# Phase 1 of the next-branch migration: this workflow runs but is a no-op +# (`if: false` on the job) until `next` exists in the repo. Once Phase 2 +# creates `next` and flips the default branch, flip the `if:` to true. +# +# See: docs/adr/230-introduce-next-integration-branch.md + +on: + push: + branches: + - main + +concurrency: + group: auto-backmerge-main-to-next + cancel-in-progress: false + +permissions: + contents: write + pull-requests: write + +jobs: + backmerge: + # Phase-1 gate: leave false until `next` exists. Flip to `true` in Phase 2. + if: false + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + fetch-depth: 0 + token: ${{ secrets.GITHUB_TOKEN }} + + - name: Verify next branch exists + id: check + run: | + if git ls-remote --exit-code origin next >/dev/null 2>&1; then + echo "next_exists=true" >> "$GITHUB_OUTPUT" + else + echo "::warning::next branch does not exist; skipping back-merge." + echo "next_exists=false" >> "$GITHUB_OUTPUT" + fi + + - name: Configure git identity + if: steps.check.outputs.next_exists == 'true' + run: | + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + + - name: Create or update back-merge branch + if: steps.check.outputs.next_exists == 'true' + id: branch + env: + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + SHORT_SHA=$(git rev-parse --short HEAD) + BR="chore/backmerge-main-to-next-${SHORT_SHA}" + echo "branch=$BR" >> "$GITHUB_OUTPUT" + + # If there's already an open back-merge PR, reuse its branch by + # updating it with the new main HEAD via a merge. Otherwise create + # a fresh branch. + EXISTING_PR=$(gh pr list --base next --label automation --label backmerge --state open \ + --json number,headRefName --jq '.[0]' 2>/dev/null || echo "") + EXISTING_BR=$(echo "$EXISTING_PR" | jq -r '.headRefName // empty') + + if [ -n "$EXISTING_BR" ]; then + echo "Updating existing back-merge branch: $EXISTING_BR" + git fetch origin "$EXISTING_BR":"$EXISTING_BR" || true + git checkout "$EXISTING_BR" + # Fast-forward to current main if possible; otherwise merge. + git merge --no-edit origin/main || { + echo "::warning::Merge conflict back-merging main into existing back-merge branch. Human resolution required." + exit 1 + } + git push origin "$EXISTING_BR" + echo "branch=$EXISTING_BR" >> "$GITHUB_OUTPUT" + echo "reused=true" >> "$GITHUB_OUTPUT" + else + git fetch origin next:next + git checkout -b "$BR" next + # Bring main's commits onto next via a merge commit (preserves tag history). + if ! git merge --no-edit -m "chore: back-merge main into next" origin/main; then + echo "::warning::Conflict during initial back-merge main → next. Pushing the branch anyway so a maintainer can resolve via PR." + # Abort and recreate as an empty branch with a CONFLICT marker — gives the maintainer a PR to work in. + git merge --abort || true + git push origin "$BR" + gh pr create \ + --base next \ + --head "$BR" \ + --title "chore: CONFLICT back-merging main → next (manual resolution required)" \ + --label automation \ + --label backmerge \ + --label needs-human \ + --body "main moved to ${SHORT_SHA} and cannot be auto-merged into next. Check out this branch locally, resolve the conflict, and push." + exit 0 + fi + git push origin "$BR" + echo "reused=false" >> "$GITHUB_OUTPUT" + fi + + - name: Open or update PR + if: steps.check.outputs.next_exists == 'true' && steps.branch.outputs.reused != 'true' + env: + GH_TOKEN: ${{ github.token }} + BR: ${{ steps.branch.outputs.branch }} + run: | + set -euo pipefail + SHORT_SHA=$(git rev-parse --short HEAD) + BODY=$(cat < re.test(head)); + if (allowed) { + core.info(`PR from ${head} → main matches an allowed pattern.`); + return; + } + + const msg = [ + `### Wrong target branch`, + ``, + `This PR targets \`main\` but the source branch \`${head}\` is not a release, hotfix, critical-fix, or back-merge branch.`, + ``, + `**Most PRs should target \`next\`, not \`main\`.** See [docs/branching.md](../blob/main/docs/branching.md).`, + ``, + `**How to fix:** click "Edit" next to the PR title above and change the base branch from \`main\` to \`next\`. No need to recreate the PR.`, + ``, + `
When IS it OK to target main?`, + ``, + `- \`release/X.Y.0\` branches (cut by \`release.yml\`)`, + `- \`hotfix/X.Y.Z\` branches (cut by \`hotfix.yml\`)`, + `- \`fix/critical-*\` branches (production-down emergencies)`, + `- \`chore/backmerge-*\` branches (automated back-merge from this workflow)`, + `- \`revert/critical-*\` branches (emergency reverts of a bad merge on main)`, + ``, + `
`, + ].join('\n'); + + // Post or update a sticky comment. + const { data: comments } = await github.rest.issues.listComments({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: pr.number, + }); + const marker = ''; + const existing = comments.find(c => c.body && c.body.includes(marker)); + const body = `${marker}\n${msg}`; + if (existing) { + await github.rest.issues.updateComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: existing.id, + body, + }); + } else { + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: pr.number, + body, + }); + } + + if (warnOnly) { + core.warning(`PR target mismatch (warning-only mode): ${head} → ${base}`); + } else { + core.setFailed(`PR target mismatch: ${head} should target next, not main.`); + } + return; + } + + // PRs targeting release/X.Y.0 or hotfix/X.Y.Z are fine (stabilization PRs). + if (/^release\/\d+\.\d+\.0$/.test(base) || /^hotfix\/\d+\.\d+\.\d+$/.test(base)) { + core.info(`PR targets a release/hotfix branch — OK.`); + return; + } + + // Any other target is unusual but not forbidden. + core.warning(`Unusual PR target: ${base}`); diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 883d1a7c2..2ef956188 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -120,6 +120,46 @@ PRs that arrive without a properly-labeled linked issue are closed automatically --- +## Where Do I Open My PR? (Branching Model) + +GSD uses two long-lived branches: `main` (production, what's on npm `@latest`) +and `next` (integration for the upcoming release). **Almost every PR targets +`next`.** Full guide: [`docs/branching.md`](docs/branching.md). + +| Your branch | PR target | Notes | +|---|---|---| +| `feat/NNN-slug` | `next` | Default for all new features | +| `fix/NNN-slug` | `next` | Default for all bug fixes; ships in next minor or via hotfix cherry-pick | +| `chore/`, `docs/`, `refactor/`, `test/`, `perf/`, `ci/`, `revert/` | `next` | All routine work | +| `fix/critical-NNN-slug` | `main` | Production-down emergencies only; auto-back-merges to `next` | +| `release/X.Y.0` | `main` | Created by `release.yml` — don't make these by hand | +| `hotfix/X.Y.Z` | `main` | Created by `hotfix.yml` — don't make these by hand | +| Stabilization PR for an in-flight release | `release/X.Y.0` | Fix a regression found during the RC cycle | + +**Day-to-day commands:** + +```bash +git fetch origin +git checkout next +git pull --ff-only origin next +git checkout -b fix/3187-config-corruption +# ... commit, push +gh pr create --base next --repo open-gsd/get-shit-done-redux +``` + +If you target the wrong branch by accident, the `PR Target Validator` +workflow will post a comment with the one-line fix (click "Edit" by the PR +title and change the base branch — no need to recreate the PR). + +**Why this matters:** Under the old single-branch model, every PR required +rebasing onto `main` because branch protection required "up-to-date before +merging" and `main` moved on every merge. With `next` as the integration +branch and that flag disabled on `next`, concurrent PRs can merge in any +order as long as they don't conflict on the same lines. The rebase +treadmill is gone for the 95% case. + +--- + ## Pull Request Guidelines ### Architecture & Domain Standards (Maintainer-Defined) diff --git a/docs/adr/230-introduce-next-integration-branch.md b/docs/adr/230-introduce-next-integration-branch.md new file mode 100644 index 000000000..81c3d6949 --- /dev/null +++ b/docs/adr/230-introduce-next-integration-branch.md @@ -0,0 +1,346 @@ +# Introduce `next` as a long-lived integration branch + +- **Status:** Proposed +- **Date:** 2026-05-23 + +> **Filename note.** This ADR uses the placeholder (now resolved to `230`) per +> [CONTRIBUTING.md §Proposing an ADR](../../CONTRIBUTING.md). Before merging, +> open a `chore:` issue, replace `XXXX` with the assigned issue number, and +> rename the file accordingly. + +## Context + +Today every contributor branch — `feat/`, `fix/`, `chore/`, `docs/`, +`refactor/`, `test/`, `perf/`, `ci/`, `revert/` — is cut from `main` and PR'd +back to `main`. Release branches (`release/X.Y.0`) and hotfix branches +(`hotfix/X.Y.Z`) are also cut from `main`. As a result: + +1. **`main` moves on every merge.** With ~315 unreleased changesets queued + and multiple PRs in flight at any time, `main` advances multiple times a + day. +2. **GitHub branch protection on `main` requires "branches up to date before + merging"** (the dominant pattern across mature OSS projects with linear + history). Every time another PR lands, every in-flight PR must rebase + before its own merge button enables. +3. **`release/X.Y.0` accumulates RC-cycle fixes that drift from `main`.** + When `finalize` opens the merge-back PR, the diff is large and + contributors who PR'd to `release/*` can't be sure their fix is also + queued for the next minor. +4. **`hotfix.yml` cherry-picks `fix:`/`chore:` commits from `main` since the + prior tag.** This works today only because every fix lands on `main`. + The pattern is fragile — any deviation (e.g. fix landing on `release/*`) + is invisible to the picker. v1.42.3 (#3621) shipped a half-state for + exactly this class of reason. + +The maintainer's stated pain: *"every update doesn't mean the next pr needs +a rebase"* — i.e. the rebase treadmill from (2), driven by (1). + +## Decision + +Introduce **`next`** as a long-lived integration branch. + +- All work that today targets `main` instead targets `next`, with the sole + exceptions of `release/X.Y.0` and `hotfix/X.Y.Z` branches, which still + merge to `main`. +- `next` is always at-or-ahead of `main`. Any push to `main` (release or + hotfix merge) triggers an automated back-merge PR `main → next` to keep + `next` aligned. +- Branch protection rules differ: + - **`main`** — strict: 2 reviewer approvals, all CI green, "require + branches up to date" **ON**, signed commits, restrict push to maintainers + via PR only. + - **`next`** — loose: 1 reviewer approval, all CI green, "require branches + up to date" **OFF**, auto-delete source branches. +- Default branch (in repo Settings) becomes `next`. `gh pr create` and the + GitHub web UI then default new PRs to the correct target without a flag. + +### Where each branch type goes + +| Branch prefix | Today's target | New target | Rationale | +|---|---|---|---| +| `feat/` | `main` | `next` | Features ship in minor releases | +| `fix/` | `main` | `next` | Regular fixes ship in minor (or get cherry-picked by hotfix.yml) | +| `chore/`, `docs/`, `refactor/`, `test/`, `perf/`, `ci/`, `revert/` | `main` | `next` | All same-flow as fixes | +| `fix/critical-*` | `main` | `main` | Production-down only, auto-back-merges to `next` | +| `release/X.Y.0` | `main` | `main` (cut from `next`) | Promoted to production on finalize | +| `hotfix/X.Y.Z` | `main` | `main` (cut from prior tag, cherry-picks from `next`) | Patch releases | + +### Mechanical changes summary + +| Component | Change | +|---|---| +| `release.yml` (`create`) | Branch from `next`, not `main` | +| `release.yml` (`finalize`) | Open merge-back PR to **both** `main` and `next` (was just `main`) | +| `hotfix.yml` (cherry-pick step) | Cherry-pick from `origin/next`, not `origin/main` | +| `hotfix.yml` (`finalize`) | Open merge-back PRs to both `main` and `next` (was just `main`) | +| `branch-naming.yml` | Add `next` to `alwaysValid` list | +| `auto-branch.yml` | Branch from `next` HEAD instead of `main` HEAD for issue-labeled branches | +| New `pr-target-validator.yml` | Block PRs targeting `main` from branches that aren't `release/*`, `hotfix/*`, or `fix/critical-*` | +| New `auto-backmerge.yml` | On push to `main`, open `main → next` PR | +| Repo settings | Default branch = `next`; squash-merge only on `next`; merge-commit on `main` (preserve tag context) | +| `scripts/setup-branch-protection.sh` | New: idempotent script to apply both branch protection rule sets via `gh api` | + +## Consequences + +### Positive + +- **Rebase treadmill ends.** PRs targeting `next` are not gated on + "up-to-date before merge". Concurrent PRs to `next` merge in any order as + long as they don't conflict on the same lines. +- **`main` becomes a stable reference.** It changes only on release/hotfix + merges — a handful of times per week, not multiple times per day. CI on + `main` runs less; downstream consumers (linked CI, npm tag watchers) see + fewer transient states. +- **Hotfix cherry-pick base is unambiguous.** All `fix:`/`chore:` commits + candidate for a hotfix live on `next`. The cherry-pick filter (today + hardcoded against `origin/main`) becomes correct-by-construction once + retargeted to `origin/next`. +- **RC-only fixes flow back to `next` automatically.** Today a fix that + lands on `release/1.28.0` to unblock RC2 only makes it to `next`-equivalent + (i.e. `main`) when finalize back-merges. Under the new model finalize + back-merges to both `main` and `next`, so an RC fix is never accidentally + dropped from the next minor. +- **Default branch switch is one click.** Cost is low; setting takes effect + for every new PR and clone immediately. + +### Negative + +- **One more concept to teach contributors.** Mitigated by `docs/branching.md` + + CONTRIBUTING update + PR-target validator that says "retarget to `next`" + with a one-line fix instruction. +- **Hotfix and release workflows need updates.** Patches are inlined below. + Both are reversible — if a patch causes pain, revert and re-target the + workflows back to `main`. No on-disk state migration required. +- **The auto-backmerge PR is a new background-noise source.** It opens + silently after each release/hotfix push to `main`. Mitigated by labeling + the PR `automation` and auto-merging if CI passes (configurable in + `auto-backmerge.yml`). +- **Existing 315-changeset queue.** Doesn't strictly block this change but + the next release will be a large one. Recommend cutting `1.28.0` from + `main` (current behavior, last time) before flipping the default branch + to `next` — see "Migration" below. + +### Risks not worth the trade-off + +We considered and rejected: + +- **`develop` instead of `next`.** The git-flow nomenclature is established + but the gitflow model itself is heavier than this project needs (no + long-lived `release/*` branches per-major, no `support/*` for old majors). + `next` matches the existing npm dist-tag (`@next`) and is the convention + for Angular, Next.js, React Native, and others. Use the name that already + appears in `VERSIONING.md`. +- **Merge queue.** GitHub's merge queue (GA in 2023) addresses the same + pain by serializing merges and rebasing+testing automatically. Rejected + because (a) it doesn't address the parallel work-stream separation that a + `next` branch gives, (b) it still requires "branches up to date" which we + want to relax, and (c) the maintainer is a git beginner and merge queue's + failure modes (split commits, requeued PRs) are harder to debug than a + conventional model. +- **Pure trunk-based with feature flags.** Rejected because the project + publishes to npm and doesn't have a runtime feature-flag system. Feature + flags would be a larger separate investment. + +## Migration + +This is a phase-gated rollout. Each phase is reversible. + +### Phase 0 — Decide + +1. Open a `chore:` issue: "Introduce `next` integration branch". Get the + issue number. Rename this ADR file from `XXXX-` to `-`. +2. Review this ADR. Decide on the merge-commit-vs-squash policy for `next` + (recommended: squash) and for `main` (recommended: merge commit on + release back-merges, to preserve the tag-commit relationship). + +### Phase 1 — Additive infrastructure (no behavior change) + +The following land on `main` (current model, one last time) before flipping: + +- `docs/branching.md` (new) +- `docs/adr/-introduce-next-integration-branch.md` (this file) +- `scripts/setup-branch-protection.sh` (new) +- `.github/workflows/auto-backmerge.yml` (new, disabled with + `if: false` until phase 2) +- `.github/workflows/pr-target-validator.yml` (new, in "warning only" mode) +- `.github/workflows/branch-naming.yml` (update: add `next` to `alwaysValid`) +- CONTRIBUTING.md update: "Where do I open my PR?" section + +### Phase 2 — Flip + +When the next release is ready to start its RC cycle: + +1. Cut the current planned release (e.g. `1.28.0`) using `release.yml` as + today — this drains the 315-changeset queue from `main` cleanly. +2. After `v1.28.0` finalizes and back-merges to `main`, run: + ```bash + git checkout main && git pull --ff-only + git checkout -b next && git push -u origin next + ``` +3. Apply branch protection: `bash scripts/setup-branch-protection.sh`. +4. Settings → Branches → change default branch to `next`. +5. Re-enable `auto-backmerge.yml` (remove the `if: false`). +6. Flip `pr-target-validator.yml` from warning-only to enforcing. + +### Phase 3 — Retarget release/hotfix workflows + +Apply these patches once `next` is established and the team has run at +least one feature PR through it. + +**`release.yml` — branch from `next` (create step):** + +```diff +@@ create: + - name: Create release branch + env: + BRANCH: ${{ needs.validate-version.outputs.branch }} + VERSION: ${{ inputs.version }} + IS_MAJOR: ${{ needs.validate-version.outputs.is_major }} + run: | ++ git fetch origin next:next || git fetch origin main:main +- git checkout -b "$BRANCH" ++ git checkout -b "$BRANCH" next 2>/dev/null || git checkout -b "$BRANCH" main +``` + +The `|| main` fallback is for the transition window where `next` may not +yet exist. After Phase 2 the fallback can be removed. + +**`release.yml` — back-merge to both branches (finalize step):** + +```diff +@@ Create PR to merge release back to main + - name: Create PR to merge release back to main + ... ++ - name: Create PR to merge release back to next ++ if: ${{ !inputs.dry_run }} ++ continue-on-error: true ++ env: ++ GH_TOKEN: ${{ github.token }} ++ BRANCH: ${{ needs.validate-version.outputs.branch }} ++ VERSION: ${{ inputs.version }} ++ run: | ++ EXISTING_PR=$(gh pr list --base next --head "$BRANCH" --state open --json number --jq '.[0].number' 2>/dev/null || echo "") ++ if [ -n "$EXISTING_PR" ]; then ++ gh pr edit "$EXISTING_PR" \ ++ --title "chore: merge release v${VERSION} to next" \ ++ --body "Merge release branch back to next after v${VERSION} stable release (picks up RC-only fixes)." \ ++ || echo "::warning::Could not update next merge-back PR. Open it manually." ++ else ++ gh pr create \ ++ --base next \ ++ --head "$BRANCH" \ ++ --title "chore: merge release v${VERSION} to next" \ ++ --body "Merge release branch back to next after v${VERSION} stable release (picks up RC-only fixes)." \ ++ || echo "::warning::Could not create next merge-back PR. Open it manually." ++ fi +``` + +**`hotfix.yml` — cherry-pick from `next` (with `main` fallback):** + +```diff +@@ Cherry-pick fix/chore commits from origin/main since base tag +- - name: Cherry-pick fix/chore commits from origin/main since base tag ++ - name: Cherry-pick fix/chore commits from origin/next since base tag + ... + run: | + set -euo pipefail +- git fetch origin main:refs/remotes/origin/main ++ # Prefer next; fall back to main during the transition window or ++ # for production-down emergencies that landed directly on main. ++ if git ls-remote --exit-code origin next >/dev/null 2>&1; then ++ git fetch origin next:refs/remotes/origin/next ++ SOURCE="origin/next" ++ else ++ git fetch origin main:refs/remotes/origin/main ++ SOURCE="origin/main" ++ fi + +- CANDIDATES=$(git cherry "$BASE_TAG" origin/main | awk '/^\+ / {print $2}') ++ CANDIDATES=$(git cherry "$BASE_TAG" "$SOURCE" | awk '/^\+ / {print $2}') +... +- ORDERED=$(git log --reverse --format='%H' "$BASE_TAG..origin/main" \ ++ ORDERED=$(git log --reverse --format='%H' "$BASE_TAG..$SOURCE" \ + | grep -F -f <(echo "$CANDIDATES") || true) +``` + +**`hotfix.yml` — back-merge to both branches (finalize step):** + +```diff +@@ Create PR to merge hotfix back to main + - name: Create PR to merge hotfix back to main + ... ++ - name: Create PR to merge hotfix back to next ++ if: ${{ !inputs.dry_run }} ++ env: ++ GH_TOKEN: ${{ github.token }} ++ BRANCH: ${{ needs.validate-version.outputs.branch }} ++ VERSION: ${{ inputs.version }} ++ run: | ++ EXISTING_PR=$(gh pr list --base next --head "$BRANCH" --state open --json number --jq '.[0].number') ++ if [ -n "$EXISTING_PR" ]; then ++ gh pr edit "$EXISTING_PR" \ ++ --title "chore: merge hotfix v${VERSION} back to next" \ ++ --body "Merge hotfix changes back to next after v${VERSION} release." ++ else ++ gh pr create \ ++ --base next \ ++ --head "$BRANCH" \ ++ --title "chore: merge hotfix v${VERSION} back to next" \ ++ --body "Merge hotfix changes back to next after v${VERSION} release." ++ fi +``` + +**`auto-branch.yml` — branch from `next`:** + +```diff +- // Create branch from main HEAD +- const mainRef = await github.rest.git.getRef({ ++ // Create branch from next HEAD (fall back to main if next missing) ++ let baseRef; ++ try { ++ baseRef = await github.rest.git.getRef({ + owner: context.repo.owner, + repo: context.repo.repo, +- ref: 'heads/main', +- }); ++ ref: 'heads/next', ++ }); ++ } catch (e) { ++ if (e.status !== 404) throw e; ++ baseRef = await github.rest.git.getRef({ ++ owner: context.repo.owner, ++ repo: context.repo.repo, ++ ref: 'heads/main', ++ }); ++ } + + await github.rest.git.createRef({ + owner: context.repo.owner, + repo: context.repo.repo, + ref: `refs/heads/${branch}`, +- sha: mainRef.data.object.sha, ++ sha: baseRef.data.object.sha, + }); +``` + +### Phase 4 — Cleanup + +After 2-3 successful releases under the new model: + +- Remove the `|| main` fallbacks from `release.yml` and `hotfix.yml`. +- Remove `develop` from `branch-naming.yml` `alwaysValid` (it was vestigial; + the project never used it). +- Drop warning-only mode from `pr-target-validator.yml`. + +## References + +- `docs/branching.md` — contributor-facing how-to-use-it guide +- `VERSIONING.md` — semver tiers and npm dist-tag mapping +- `.github/workflows/release.yml`, `.github/workflows/hotfix.yml` — + release/hotfix automation that this ADR adjusts +- `scripts/setup-branch-protection.sh` — bootstrap script for branch + protection rules +- [Angular branching model](https://github.com/angular/angular/wiki/Branching-Strategy) + — closest analogue (`main` + `-next`) +- [Next.js release flow](https://github.com/vercel/next.js#contributing) + — uses `canary` as the integration branch with the same shape diff --git a/docs/branching.md b/docs/branching.md new file mode 100644 index 000000000..8d286d0ad --- /dev/null +++ b/docs/branching.md @@ -0,0 +1,272 @@ +# Branching Model + +A plain-English guide to where work happens, where it merges, and why +you should almost never need to rebase. + +> **Audience:** maintainers and contributors. Aimed at people who can use git +> but don't want to think about it every day. If you came up through CIS in the +> late 90s and never quite warmed to git's mental model — this guide is for you. + +--- + +## TL;DR + +``` + feat/NNN-slug ─┐ + fix/NNN-slug ─┤ + chore/NNN-slug ┼──► next (integration) ──► release/X.Y.0 ──► main ──► v-tag ──► npm @latest + docs/NNN-slug ─┘ │ + │ + hotfix/X.Y.Z ◄── branch from v-tag ──────┐ │ + cherry-pick from next ─────┤ │ + ──────────────────────────►│──► main ──►v-tag (patch) ──► npm @latest + │ │ + └─► next ◄──┘ (auto back-merge) +``` + +Three rules: + +1. **You PR to `next`. Not `main`.** Unless you're cutting a release or a + hotfix, the answer to "where does my PR go?" is always `next`. +2. **`main` only moves on release.** Maintainers (and the release/hotfix + workflows) push to `main`. Feature work never touches it. +3. **Hotfixes go to `main` first, then auto-merge back to `next`.** Urgent + fixes don't wait for the release train. + +--- + +## The two long-lived branches + +### `main` — production + +- Represents what's on `npm @latest` right now. +- Only changes when a release lands, a hotfix lands, or — rarely — when an + emergency fix is cherry-picked. +- Tagged with `vX.Y.Z` on every release. +- Branch protection: 2 approvals, CI green, signed commits, linear history + not required (release back-merges use merge commits to preserve tag context). +- Never push directly. Even maintainers go through PRs. + +### `next` — integration for the next release + +- Represents what will be in the **next** minor or major release. +- This is where day-to-day feature, fix, chore, docs, and refactor work lands. +- Always at-or-ahead of `main`: contains everything in `main` plus everything + queued for the next release. +- Branch protection: 1 approval, CI green, auto-deleted source branches. +- Auto-back-merged from `main` whenever `main` advances (via + `auto-backmerge.yml`) so it never falls behind. + +--- + +## Short-lived branches + +These are work branches. Open one, push commits, PR it, merge it, let it auto-delete. + +| Prefix | What it's for | PR target | Examples | +|---|---|---|---| +| `feat/` | New feature (approved-feature issue) | `next` | `feat/3201-discuss-mode` | +| `fix/` | Bug fix (confirmed-bug issue) | `next` | `fix/3187-config-corruption` | +| `chore/` | Maintenance, refactors, deps, CI | `next` | `chore/3712-bump-node-actions` | +| `docs/` | Documentation only | `next` | `docs/3485-adr-naming-convention` | +| `refactor/` | Internal restructuring, no behavior change | `next` | `refactor/3500-extract-seam` | +| `test/` | Test-only additions or corrections | `next` | `test/3621-restore-fixture` | +| `perf/` | Performance work, no behavior change | `next` | `perf/3300-skill-index` | +| `ci/` | CI/workflow changes only | `next` | `ci/3801-add-node-26-matrix` | +| `revert/` | Reverting a previously-merged change | `next` (or `main` if urgent) | `revert/3919-bad-merge` | +| `hotfix/X.Y.Z` | Patch release branch (created by `hotfix.yml`) | `main` | `hotfix/1.27.1` | +| `release/X.Y.0` | Minor/major release branch (created by `release.yml`) | `main` | `release/1.28.0` | + +> **The branch name rule is enforced** by `.github/workflows/branch-naming.yml`. +> A PR from a non-conforming branch gets a warning comment. Pick a prefix. + +--- + +## Three flows: feature, hotfix, release + +### Flow 1 — Feature / fix / chore (the 95% case) + +This is what you do every day. + +```bash +# 1. Start from a fresh `next`. +git fetch origin +git checkout next +git pull --ff-only origin next + +# 2. Branch off `next`. +git checkout -b fix/3187-config-corruption + +# 3. Work. Commit. Push. +git add -A && git commit -m "fix: harden config parse for trailing comma" +git push -u origin fix/3187-config-corruption + +# 4. Open a PR. Target = next. (Don't change the target.) +gh pr create --base next --repo open-gsd/get-shit-done-redux + +# 5. CI runs. Reviewer approves. PR squash-merges into `next`. +# Your branch auto-deletes. +``` + +**Why this rarely needs a rebase:** + +- `next` moves slower than `main` did in the old single-branch model — only + changes when other PRs to `next` merge. +- Branch protection on `next` does **not** require "up-to-date before merge" + — only requires CI to be green. So another PR landing on `next` while yours + is open doesn't force you to rebase. +- Squash-merge means each PR becomes one commit on `next` — easy to revert, + easy to read, no merge-noise. + +### Flow 2 — Hotfix (urgent patch release) + +When you need to ship a fix without waiting for the next minor. + +```bash +# 1. The fix itself lands on `next` first (or main, for true emergencies). +# Open the PR exactly like a normal fix. +git checkout next +git pull --ff-only +git checkout -b fix/3919-critical-crash +# ... commit, push, PR to next, merge. + +# 2. Trigger the hotfix workflow from the Actions tab: +# workflow: Hotfix Release +# action: create +# version: 1.27.1 (next patch number) +# auto_cherry_pick: true (default) +``` + +The workflow: + +1. Finds the prior tag (`v1.27.0`) — the cherry-pick base. +2. Creates `hotfix/1.27.1` from that tag. +3. Cherry-picks every `fix:` / `chore:` commit on `next` (or `main`) that + isn't already in `v1.27.0`, oldest-first. +4. Bumps versions, pushes the branch. +5. You review the branch. Trigger `finalize` when ready. +6. `finalize` publishes to npm `@latest`, tags `v1.27.1`, opens + merge-back PRs to **both** `main` and `next`. + +> See `.github/workflows/hotfix.yml` and `VERSIONING.md` for the deep dive. + +### Flow 3 — Minor or major release + +When `next` has accumulated enough work to ship a new minor/major. + +``` +1. Maintainer triggers Actions → Release workflow: + - action: create + - version: 1.28.0 + +2. Workflow cuts release/1.28.0 from `next` (not main). + `next` keeps moving — new feature PRs can target it. + +3. Stabilization on release/1.28.0: + - Bug fixes can be PR'd to release/1.28.0 directly (`fix/3923-rc-blocker`) + - Each fix should also be PR'd to `next` so the next release has it too + (or wait for the auto-back-merge after finalize) + - Trigger `rc` action to publish RC builds: 1.28.0-rc.1, rc.2, ... + +4. When stable: trigger `finalize`. + - Publishes to npm @latest + - Tags v1.28.0 + - Opens merge-back PR: release/1.28.0 → main + - Opens merge-back PR: release/1.28.0 → next (NEW — picks up RC-only fixes) + +5. Merge both back-merge PRs. release/1.28.0 branch is deleted. +``` + +--- + +## Why this fixes the "constant rebase" pain + +The old single-branch model: + +- Every PR targets `main`. +- `main` moves every time anything lands. +- Branch protection often requires "branches up to date" — so the moment + someone else's PR merges, your PR demands a rebase before it can merge. +- 10 PRs in flight = 9 of them need to rebase every time one lands. The + last one to merge has rebased N times. + +The new model removes the pressure in three ways: + +1. **`main` rarely moves.** Only release/hotfix merges land. Most days `main` + doesn't change at all. +2. **`next` does NOT require "up-to-date before merge".** Branch protection + on `next` only requires CI green. Two PRs to `next` can merge in either + order without rebasing each other — git will handle the merge as long as + they don't touch the same lines. +3. **Squash-merge into `next`.** One commit per PR. No "merge main into my + branch" noise. If you ever do need to bring `next` into your branch + (because of a real conflict), it's one rebase per conflict, not per PR + that lands somewhere else. + +You'll still occasionally rebase — when your branch genuinely conflicts with +something that landed on `next`. But that's a real conflict you'd have to +resolve anyway. The treadmill is gone. + +--- + +## Cheat sheet: "where does my PR go?" + +``` +Is it a hotfix release branch? → main (cut by hotfix.yml) +Is it a stable release branch? → main (cut by release.yml) +Is it an RC-blocker fix? → release/X.Y.0 (and also next, or rely on back-merge) +Is it everything else? → next +``` + +If you're unsure: `next`. The PR target validator (`pr-target-validator.yml`) +will reject PRs that target `main` from non-release/hotfix branches and tell +you to retarget. Use the GitHub UI "Edit" button on the PR title to change +the base branch — no need to recreate the PR. + +--- + +## Maintainer reference + +- **First-time bootstrap:** run `scripts/setup-branch-protection.sh` after + creating the `next` branch for the first time. It applies protection rules + to both `main` and `next` consistently. +- **Repo Settings → General → Pull Requests:** + - Allow squash merging — **ON** (default merge strategy) + - Allow merge commits — **ON** (needed for release/hotfix back-merges) + - Allow rebase merging — **OFF** (avoids confusion) + - Default to "Pull request title" for squash commit messages — **ON** + - Automatically delete head branches — **ON** + - Allow auto-merge — **ON** (the auto-backmerge workflow uses it) +- **Creating `next` for the first time:** + ```bash + git checkout main + git pull --ff-only + git checkout -b next + git push -u origin next + # Then go to repo Settings → Branches and set `next` as the default branch + # (so `gh pr create` and the GitHub web UI default to it). + ``` +- **Phasing in:** see the migration notes in + `docs/adr/230-introduce-next-integration-branch.md`. +- **Updating release.yml / hotfix.yml:** these workflows currently branch + from `main` and cherry-pick from `main`. After phase-2 of the migration + they should branch from `next` (release) and cherry-pick from `next` + (hotfix). The patches are inlined in the ADR. + +--- + +## When NOT to use `next` + +A few exceptions where the rules above bend: + +- **True production-down emergency.** Push directly to a `fix/critical-*` + branch, PR to `main`. The auto-back-merge workflow will replay it onto + `next` within minutes. Use sparingly — most "urgent" things are fine to go + through `next` and ship same day via the hotfix workflow. +- **Documentation-only typo on a published page.** If the only change is a + doc fix that's visible right now and shouldn't wait for the next release, + PR it to `main`. The auto-back-merge will sync `next`. Most doc changes + should still go through `next` and ship with the next release. +- **CI / workflow files.** Strictly speaking these affect the repo state, not + the published artifact. They can target `next` or `main`. Convention: PR + to `next` unless the CI fix unblocks a release in progress. diff --git a/next-branch-files.tar.gz b/next-branch-files.tar.gz new file mode 100644 index 0000000000000000000000000000000000000000..6d4a907284f2efb4750bc5670ec5051f02579965 GIT binary patch literal 14810 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 AQ2+n{ literal 0 HcmV?d00001 diff --git a/rollout-next-phase1.sh b/rollout-next-phase1.sh new file mode 100755 index 000000000..4527ac296 --- /dev/null +++ b/rollout-next-phase1.sh @@ -0,0 +1,546 @@ +#!/usr/bin/env zsh +# rollout-next-phase1.sh — native zsh, runs under macOS Terminal's default shell. +# DO NOT prefix with `bash`. Run as: ./rollout-next-phase1.sh +# +# Phase 1 of the `next` integration-branch rollout. +# +# What it does (in order, on YOUR Mac, in your repo dir): +# 1. Stashes any uncommitted work on your current branch (codex/...) with a +# named stash you can recover with `git stash list` + `git stash pop`. +# 2. Switches to main, pulls --ff-only to be sure you're current. +# 3. Creates branch: chore/introduce-next-integration-branch +# 4. Unpacks the 5 new files from the tarball. +# 5. Applies the 2 edits (branch-naming.yml, CONTRIBUTING.md) programmatically +# against the clean origin/main versions — no chance of pulling in +# unrelated commits from your current codex/ branch. +# 6. Sanity-checks YAML + bash syntax. +# 7. Commits with a conventional-commit message. +# 8. Pushes the branch to origin. +# 9. Files a `type: chore` issue using gh. +# 10. Renames the ADR file XXXX-...md → -...md and replaces XXXX in +# the ADR body with the real issue number. +# 11. Amends the commit, force-pushes-with-lease. +# 12. Opens the PR against main with `Closes #` in the body. +# +# Idempotent: re-running after a failure converges. Each step checks if it's +# already done. +# +# Usage: +# cd /Volumes/Mini\ Me/Users/trekkie/projects/get-shit-done +# bash /path/to/rollout-next-phase1.sh +# +# Env overrides: +# TARBALL=/path/to/next-branch-files.tar.gz (default: ./next-branch-files.tar.gz) +# REPO=open-gsd/get-shit-done-redux (default: that) +# DRY_RUN=1 (skip push, issue, PR) + +set -euo pipefail + +# ─────────────────────────────────────────────────────────── +# Config +# ─────────────────────────────────────────────────────────── +REPO="${REPO:-open-gsd/get-shit-done-redux}" +TARBALL="${TARBALL:-./next-branch-files.tar.gz}" +BRANCH="chore/introduce-next-integration-branch" +DRY_RUN="${DRY_RUN:-0}" + +# Colors for readability (no-op if not a tty) +if [ -t 1 ]; then + C_BOLD=$'\033[1m'; C_GRN=$'\033[32m'; C_YEL=$'\033[33m'; C_RED=$'\033[31m'; C_DIM=$'\033[2m'; C_RST=$'\033[0m' +else + C_BOLD=''; C_GRN=''; C_YEL=''; C_RED=''; C_DIM=''; C_RST='' +fi + +step() { echo; echo "${C_BOLD}▸ $*${C_RST}"; } +ok() { echo "${C_GRN} ✓${C_RST} $*"; } +warn() { echo "${C_YEL} ⚠${C_RST} $*"; } +die() { echo "${C_RED} ✗ $*${C_RST}" >&2; exit 1; } +note() { echo "${C_DIM} $*${C_RST}"; } + +# ─────────────────────────────────────────────────────────── +# Step 0: Sanity checks +# ─────────────────────────────────────────────────────────── +step "Sanity checks" + +[ -d .git ] || die "Not in a git repo. cd to your get-shit-done checkout first." +command -v gh >/dev/null || die "gh CLI not found. Install from https://cli.github.com/" +command -v jq >/dev/null || die "jq not found. Install: brew install jq" +gh auth status >/dev/null 2>&1 || die "gh not authenticated. Run: gh auth login" +[ -f "$TARBALL" ] || die "Tarball not found at: $TARBALL (override with TARBALL=/path/to/next-branch-files.tar.gz)" + +# Verify we're pointed at the right remote. +REMOTE_URL=$(git remote get-url origin 2>/dev/null || true) +case "$REMOTE_URL" in + *"$REPO"*) ok "Remote: $REMOTE_URL" ;; + *) die "Remote 'origin' is $REMOTE_URL — expected to contain $REPO. Wrong checkout?" ;; +esac + +# Verify tarball contents look right (fail loudly if user grabbed the wrong tar). +EXPECTED_PATHS=( + "docs/branching.md" + "docs/adr/XXXX-introduce-next-integration-branch.md" + ".github/workflows/auto-backmerge.yml" + ".github/workflows/pr-target-validator.yml" + "scripts/setup-branch-protection.sh" +) +TARLIST=$(tar tzf "$TARBALL") +for p in "${EXPECTED_PATHS[@]}"; do + echo "$TARLIST" | grep -qx "$p" || die "Tarball missing expected file: $p" +done +ok "Tarball contains all 5 expected files" + +# ─────────────────────────────────────────────────────────── +# Step 1: Stash any uncommitted work (protecting rollout files from the sweep) +# ─────────────────────────────────────────────────────────── +step "Stash any uncommitted work on current branch" + +CURRENT_BR=$(git rev-parse --abbrev-ref HEAD) +note "Currently on: $CURRENT_BR" + +# git stash --include-untracked would otherwise grab the rollout script and +# tarball (they're untracked). Move them aside, stash, move them back. +# trap EXIT guarantees restore even on script failure. +ROLLOUT_STAGE=$(mktemp -d) +ROLLOUT_FILES=(rollout-next-phase1.sh rollout-next-phase2.sh next-branch-files.tar.gz) +for f in "${ROLLOUT_FILES[@]}"; do + [ -f "./$f" ] && mv "./$f" "$ROLLOUT_STAGE/" +done +restore_rollout_files() { + # (N) is zsh's nullglob qualifier — empty match expands to nothing + # instead of erroring with "no matches found". + for f in "$ROLLOUT_STAGE"/*(N); do + cp "$f" ./ 2>/dev/null || true + done + rm -rf "$ROLLOUT_STAGE" 2>/dev/null || true +} +trap restore_rollout_files EXIT + +if [ -n "$(git status --porcelain)" ]; then + STASH_MSG="pre-next-rollout-$(date +%Y%m%d-%H%M%S) (from $CURRENT_BR)" + git stash push --include-untracked --message "$STASH_MSG" >/dev/null + ok "Stashed as: $STASH_MSG" + note "Recover later with: git stash list then git stash pop " +else + ok "Working tree clean — nothing to stash" +fi + +# Bring rollout files back into the working tree NOW so subsequent retries can find them. +restore_rollout_files + +# ─────────────────────────────────────────────────────────── +# Step 2: Switch to main, pull +# ─────────────────────────────────────────────────────────── +step "Switch to main and pull" + +git fetch origin --quiet +git checkout main >/dev/null 2>&1 || die "Could not checkout main" +git pull --ff-only origin main >/dev/null +ok "main is current at $(git log -1 --format='%h %s' | head -c 80)" + +# ─────────────────────────────────────────────────────────── +# Step 3: Create or switch to rollout branch +# ─────────────────────────────────────────────────────────── +step "Create branch $BRANCH" + +# Prune any stale worktree registrations first (e.g. from a prior aborted +# attempt that left .git/worktrees/ pointing at a deleted directory). +git worktree prune 2>/dev/null || true + +if git show-ref --verify --quiet "refs/heads/$BRANCH"; then + # Detect if a worktree still claims this branch. + # NB: no `exit` in awk — that would SIGPIPE git and trip pipefail/set -e + # silently. `head -1 || true` neutralizes the SIGPIPE we inflict ourselves. + CLAIMING_WT=$( { git worktree list --porcelain 2>/dev/null || true; } | awk -v br="refs/heads/$BRANCH" ' + /^worktree / { wt=$2 } + $0 == "branch " br { print wt } + ' | head -1 || true) + if [ -n "$CLAIMING_WT" ] && [ "$CLAIMING_WT" != "$(pwd)" ]; then + die "Branch $BRANCH is held by worktree $CLAIMING_WT. Run: git worktree remove --force '$CLAIMING_WT' (or) git worktree prune then re-run." + fi + warn "Branch $BRANCH already exists locally. Switching to it." + git checkout "$BRANCH" >/dev/null + if git rev-parse --verify "origin/$BRANCH" >/dev/null 2>&1; then + warn "origin/$BRANCH already exists. Resetting to main would lose remote commits — ABORT." + die "Delete the remote branch first if you want a clean restart: gh api -X DELETE /repos/$REPO/git/refs/heads/$BRANCH" + fi + git reset --hard main >/dev/null + ok "Reset local $BRANCH to current main" +else + git checkout -b "$BRANCH" >/dev/null + ok "Created and switched to $BRANCH" +fi + +# ─────────────────────────────────────────────────────────── +# Step 4: Unpack the 5 new files +# ─────────────────────────────────────────────────────────── +step "Unpack new files from tarball" + +tar xzf "$TARBALL" +chmod +x scripts/setup-branch-protection.sh +ok "Unpacked: $(tar tzf "$TARBALL" | wc -l | tr -d ' ') files" + +# ─────────────────────────────────────────────────────────── +# Step 5: Apply the 2 edits programmatically +# ─────────────────────────────────────────────────────────── +step "Apply edit 1/2 — add 'next' to branch-naming.yml alwaysValid" + +NAMING=".github/workflows/branch-naming.yml" +if grep -q "alwaysValid = \['main', 'next', 'develop'\]" "$NAMING"; then + ok "branch-naming.yml already has 'next' in alwaysValid (idempotent skip)" +else + if ! grep -q "alwaysValid = \['main', 'develop'\]" "$NAMING"; then + die "Could not find the expected anchor in $NAMING. Maybe upstream changed it. Open the file and add 'next' manually." + fi + # BSD sed (macOS default) needs -i ''; GNU sed needs -i. Use -i.bak then remove. + sed -i.rollout-bak "s/alwaysValid = \['main', 'develop'\]/alwaysValid = ['main', 'next', 'develop']/" "$NAMING" + rm -f "$NAMING.rollout-bak" + grep -q "alwaysValid = \['main', 'next', 'develop'\]" "$NAMING" || die "sed didn't take. Aborting." + ok "branch-naming.yml updated" +fi + +step "Apply edit 2/2 — insert 'Where Do I Open My PR?' section into CONTRIBUTING.md" + +CONTRIB="CONTRIBUTING.md" +if grep -q "^## Where Do I Open My PR?" "$CONTRIB"; then + ok "CONTRIBUTING.md already has the section (idempotent skip)" +else + # Anchor must exist exactly once. + ANCHOR_COUNT=$(grep -c "^## Pull Request Guidelines\$" "$CONTRIB" || true) + [ "$ANCHOR_COUNT" -eq 1 ] || die "Expected exactly 1 '## Pull Request Guidelines' anchor in $CONTRIB, found $ANCHOR_COUNT. Insert the section manually." + + # Write section to a temp file. BSD awk (macOS default) rejects newlines in + # -v variable values, so we pass a filename instead and let awk read it. + SECTION_FILE=$(mktemp -t gsd-rollout-section.XXXXXX) + cat > "$SECTION_FILE" <<'EOF' +## Where Do I Open My PR? (Branching Model) + +GSD uses two long-lived branches: `main` (production, what's on npm `@latest`) +and `next` (integration for the upcoming release). **Almost every PR targets +`next`.** Full guide: [`docs/branching.md`](docs/branching.md). + +| Your branch | PR target | Notes | +|---|---|---| +| `feat/NNN-slug` | `next` | Default for all new features | +| `fix/NNN-slug` | `next` | Default for all bug fixes; ships in next minor or via hotfix cherry-pick | +| `chore/`, `docs/`, `refactor/`, `test/`, `perf/`, `ci/`, `revert/` | `next` | All routine work | +| `fix/critical-NNN-slug` | `main` | Production-down emergencies only; auto-back-merges to `next` | +| `release/X.Y.0` | `main` | Created by `release.yml` — don't make these by hand | +| `hotfix/X.Y.Z` | `main` | Created by `hotfix.yml` — don't make these by hand | +| Stabilization PR for an in-flight release | `release/X.Y.0` | Fix a regression found during the RC cycle | + +**Day-to-day commands:** + +```bash +git fetch origin +git checkout next +git pull --ff-only origin next +git checkout -b fix/3187-config-corruption +# ... commit, push +gh pr create --base next --repo open-gsd/get-shit-done-redux +``` + +If you target the wrong branch by accident, the `PR Target Validator` +workflow will post a comment with the one-line fix (click "Edit" by the PR +title and change the base branch — no need to recreate the PR). + +**Why this matters:** Under the old single-branch model, every PR required +rebasing onto `main` because branch protection required "up-to-date before +merging" and `main` moved on every merge. With `next` as the integration +branch and that flag disabled on `next`, concurrent PRs can merge in any +order as long as they don't conflict on the same lines. The rebase +treadmill is gone for the 95% case. + +--- +EOF + # Trailing blank line so awk emits a blank between our closing rule and + # the next H2 (CommonMark requires blank before any header). + echo "" >> "$SECTION_FILE" + + awk -v sectionfile="$SECTION_FILE" ' + /^## Pull Request Guidelines$/ && !inserted { + while ((getline line < sectionfile) > 0) print line + close(sectionfile) + inserted = 1 + } + { print } + ' "$CONTRIB" > "$CONTRIB.tmp" + mv "$CONTRIB.tmp" "$CONTRIB" + rm -f "$SECTION_FILE" + grep -q "^## Where Do I Open My PR?" "$CONTRIB" || die "awk insertion failed." + ok "CONTRIBUTING.md section inserted" +fi + +# ─────────────────────────────────────────────────────────── +# Step 6: Sanity-check YAML + bash +# ─────────────────────────────────────────────────────────── +step "Validate YAML + bash" + +for f in .github/workflows/auto-backmerge.yml \ + .github/workflows/pr-target-validator.yml \ + .github/workflows/branch-naming.yml; do + python3 -c "import yaml,sys; yaml.safe_load(open('$f'))" \ + || die "YAML invalid: $f" +done +ok "All 3 workflow YAMLs parse" + +bash -n scripts/setup-branch-protection.sh || die "Bash syntax error: setup-branch-protection.sh" +ok "setup-branch-protection.sh syntax OK" + +# ─────────────────────────────────────────────────────────── +# Step 7: Commit +# ─────────────────────────────────────────────────────────── +step "Commit" + +git add docs/branching.md \ + docs/adr/XXXX-introduce-next-integration-branch.md \ + .github/workflows/auto-backmerge.yml \ + .github/workflows/pr-target-validator.yml \ + .github/workflows/branch-naming.yml \ + CONTRIBUTING.md \ + scripts/setup-branch-protection.sh + +if git diff --cached --quiet; then + warn "Nothing to commit (already committed from a previous run?)" +else + git commit -m "chore: introduce \`next\` integration branch (Phase 1 — additive) + +Adds: + - docs/branching.md — beginner contributor guide + - docs/adr/XXXX-...md — ADR (will be renamed with issue#) + - .github/workflows/auto-backmerge.yml — disabled in Phase 1 + - .github/workflows/pr-target-validator.yml — warn-only in Phase 1 + - scripts/setup-branch-protection.sh — idempotent gh api script + +Modifies: + - .github/workflows/branch-naming.yml — recognize 'next' + - CONTRIBUTING.md — 'Where Do I Open My PR?' section + +Phase 1 is additive: nothing operational changes until Phase 2 flips +auto-backmerge.yml's if:false→true, flips pr-target-validator.yml's +WARN_ONLY→false, creates the next branch, and switches the default +branch. See the ADR for the migration plan." + ok "Committed" +fi + +# ─────────────────────────────────────────────────────────── +# Step 8: Push +# ─────────────────────────────────────────────────────────── +step "Push branch" + +if [ "$DRY_RUN" = "1" ]; then + warn "DRY_RUN=1 — skipping push" +else + git push -u origin "$BRANCH" + ok "Pushed $BRANCH" +fi + +# ─────────────────────────────────────────────────────────── +# Step 9: File the issue (or reuse if one already exists) +# ─────────────────────────────────────────────────────────── +step "File or reuse the chore issue" + +ISSUE_TITLE="chore: introduce \`next\` integration branch to end rebase treadmill" +ISSUE_BODY=$(cat <<'EOF' +## Problem + +Every contributor branch is cut from `main` and PR'd back to `main`. Combined +with branch protection's "Require branches to be up to date before merging", +this produces a rebase treadmill: every time another PR merges, every +in-flight PR demands a rebase. With ~315 unreleased changesets queued and +multiple PRs at any time, this is constant. + +## Proposed change + +Introduce `next` as a long-lived integration branch. Routine PRs target +`next`; `main` only changes on release / hotfix / emergency fix. An +auto-back-merge workflow keeps `next` aligned with `main`. + +Full design: see the ADR in this PR (`docs/adr/XXXX-introduce-next-integration-branch.md`). +Contributor-facing guide: `docs/branching.md`. + +## Scope + +This issue covers Phase 1 of the rollout (additive infrastructure — no +operational change until the workflow flags are flipped in Phase 2). + +- New: `docs/branching.md`, ADR, `auto-backmerge.yml` (disabled), + `pr-target-validator.yml` (warn-only), `setup-branch-protection.sh`. +- Modified: `branch-naming.yml` (recognize `next`), `CONTRIBUTING.md` + ("Where Do I Open My PR?" section). +- Not yet touched: `release.yml`, `hotfix.yml`, `auto-branch.yml` — these + are Phase 3, with patches inlined in the ADR. + +## Acceptance criteria + +- All 7 files land via this PR. +- CI is green. +- `docs/branching.md` renders correctly on GitHub. +- ADR filename is renamed from `XXXX-` to `-`. + +## Phase 2 follow-up (separate PR) + +After Phase 1 merges, a small follow-up PR will: +- Create the `next` branch from `main` HEAD. +- Apply branch protection via the new script. +- Switch the default branch to `next`. +- Flip `if: false` → `if: true` in `auto-backmerge.yml`. +- Flip `WARN_ONLY: 'true'` → `'false'` in `pr-target-validator.yml`. +EOF +) + +# Look for an existing open issue with this exact title to support idempotent re-runs. +EXISTING_ISSUE=$(gh issue list --repo "$REPO" --search "in:title \"$ISSUE_TITLE\"" --state open --json number --jq '.[0].number' 2>/dev/null || echo "") + +if [ -n "$EXISTING_ISSUE" ]; then + ISSUE_NUM="$EXISTING_ISSUE" + ok "Reusing existing open issue #$ISSUE_NUM" +elif [ "$DRY_RUN" = "1" ]; then + ISSUE_NUM="DRYRUN" + warn "DRY_RUN=1 — skipping issue creation; ISSUE_NUM=$ISSUE_NUM" +else + ISSUE_URL=$(echo "$ISSUE_BODY" | gh issue create \ + --repo "$REPO" \ + --title "$ISSUE_TITLE" \ + --label "type: chore" \ + --body-file -) + ISSUE_NUM=$(echo "$ISSUE_URL" | sed 's|.*/||') + ok "Filed issue #$ISSUE_NUM — $ISSUE_URL" +fi + +# ─────────────────────────────────────────────────────────── +# Step 10: Rename ADR with issue number, replace XXXX in body +# ─────────────────────────────────────────────────────────── +step "Rename ADR and substitute issue number" + +OLD_ADR="docs/adr/XXXX-introduce-next-integration-branch.md" +NEW_ADR="docs/adr/${ISSUE_NUM}-introduce-next-integration-branch.md" + +if [ -f "$OLD_ADR" ]; then + git mv "$OLD_ADR" "$NEW_ADR" + ok "Renamed: $OLD_ADR → $NEW_ADR" +elif [ -f "$NEW_ADR" ]; then + ok "ADR already renamed (idempotent skip)" +else + die "Neither $OLD_ADR nor $NEW_ADR exists. Something is off." +fi + +# Replace XXXX inside ADR with real issue number (only in the placeholder context). +if [ "$ISSUE_NUM" != "DRYRUN" ]; then + sed -i.rollout-bak "s/XXXX-introduce-next-integration-branch/${ISSUE_NUM}-introduce-next-integration-branch/g" "$NEW_ADR" + sed -i.rollout-bak "s/placeholder \`XXXX\` prefix/placeholder (now resolved to \`${ISSUE_NUM}\`)/g" "$NEW_ADR" + rm -f "$NEW_ADR.rollout-bak" + ok "Substituted XXXX → ${ISSUE_NUM} in ADR body" +fi + +# Also patch the references in the new workflow files which mention XXXX-introduce-next-integration-branch. +for f in .github/workflows/auto-backmerge.yml .github/workflows/pr-target-validator.yml docs/branching.md CONTRIBUTING.md; do + if [ -f "$f" ] && grep -q "XXXX-introduce-next-integration-branch" "$f"; then + sed -i.rollout-bak "s/XXXX-introduce-next-integration-branch/${ISSUE_NUM}-introduce-next-integration-branch/g" "$f" + rm -f "$f.rollout-bak" + ok "Updated cross-reference in $f" + fi +done + +# ─────────────────────────────────────────────────────────── +# Step 11: Amend commit + force-push +# ─────────────────────────────────────────────────────────── +step "Amend commit and force-push" + +git add -A +if git diff --cached --quiet; then + ok "No changes to amend (idempotent skip)" +else + git commit --amend --no-edit + ok "Amended commit" +fi + +if [ "$DRY_RUN" = "1" ]; then + warn "DRY_RUN=1 — skipping force-push" +else + git push --force-with-lease origin "$BRANCH" + ok "Force-pushed (with lease)" +fi + +# ─────────────────────────────────────────────────────────── +# Step 12: Open the PR (or reuse if one already exists) +# ─────────────────────────────────────────────────────────── +step "Open PR against main" + +PR_TITLE="chore: introduce \`next\` integration branch (Phase 1 — additive)" +PR_BODY=$(cat </dev/null || echo "") + +if [ -n "$EXISTING_PR" ]; then + ok "Reusing existing PR #$EXISTING_PR" + if [ "$DRY_RUN" != "1" ]; then + echo "$PR_BODY" | gh pr edit "$EXISTING_PR" --repo "$REPO" --title "$PR_TITLE" --body-file - + ok "Updated PR #$EXISTING_PR title and body" + fi +elif [ "$DRY_RUN" = "1" ]; then + warn "DRY_RUN=1 — skipping PR creation" +else + PR_URL=$(echo "$PR_BODY" | gh pr create \ + --repo "$REPO" \ + --base main \ + --head "$BRANCH" \ + --title "$PR_TITLE" \ + --body-file -) + ok "Opened PR: $PR_URL" +fi + +# ─────────────────────────────────────────────────────────── +# Done. +# ─────────────────────────────────────────────────────────── +echo +echo "${C_BOLD}${C_GRN}━━━ Phase 1 complete ━━━${C_RST}" +echo +echo "Issue: #${ISSUE_NUM}" +echo "Branch: $BRANCH" +echo "PR: $(gh pr list --repo "$REPO" --head "$BRANCH" --state open --json url --jq '.[0].url' 2>/dev/null || echo "(check gh pr list)")" +echo +echo "Next steps:" +echo " 1. Review CI on the PR. The Changeset Required check may ask for a" +echo " changeset fragment — drop one if needed:" +echo " npm run changeset -- --type Changed --pr \\" +echo " --body \"Introduce \\\`next\\\` integration branch (Phase 1 — additive infrastructure).\"" +echo " 2. Get an approval, merge." +echo " 3. Run Phase 2: bash /path/to/rollout-next-phase2.sh" +echo +echo "Recovering your stashed work on $CURRENT_BR (if you had any):" +echo " git checkout $CURRENT_BR" +echo " git stash list # find the 'pre-next-rollout-...' entry" +echo " git stash pop stash@{N}" diff --git a/rollout-next-phase2.sh b/rollout-next-phase2.sh new file mode 100755 index 000000000..54831bfdb --- /dev/null +++ b/rollout-next-phase2.sh @@ -0,0 +1,300 @@ +#!/usr/bin/env zsh +# rollout-next-phase2.sh — native zsh, runs under macOS Terminal's default shell. +# DO NOT prefix with `bash`. Run as: ./rollout-next-phase2.sh +# +# Phase 2 of the `next` integration-branch rollout. +# RUN THIS ONLY AFTER THE PHASE-1 PR HAS BEEN MERGED TO main. +# +# What it does: +# 1. Pulls main (which now contains Phase 1). +# 2. Creates the `next` branch from main HEAD, pushes it. +# 3. Applies branch protection rules via the script committed in Phase 1. +# 4. Switches the repo's default branch to `next` via gh api. +# 5. Flips `if: false` → `if: true` in auto-backmerge.yml. +# 6. Flips `WARN_ONLY: 'true'` → `'false'` in pr-target-validator.yml. +# 7. Commits the flips on a small follow-up branch, opens a PR to next. +# +# Idempotent: re-running converges. Each step checks if it's already done. +# +# Usage: +# cd /Volumes/Mini\ Me/Users/trekkie/projects/get-shit-done +# bash /path/to/rollout-next-phase2.sh +# +# Env overrides: +# REPO=open-gsd/get-shit-done-redux (default) +# SKIP_PROTECTION=1 (skip running setup-branch-protection.sh) +# SKIP_DEFAULT_FLIP=1 (skip switching default branch) +# DRY_RUN=1 (skip all pushes, PRs, and api writes) + +set -euo pipefail + +REPO="${REPO:-open-gsd/get-shit-done-redux}" +FLIP_BRANCH="chore/flip-next-rollout-flags" +DRY_RUN="${DRY_RUN:-0}" + +if [ -t 1 ]; then + C_BOLD=$'\033[1m'; C_GRN=$'\033[32m'; C_YEL=$'\033[33m'; C_RED=$'\033[31m'; C_DIM=$'\033[2m'; C_RST=$'\033[0m' +else + C_BOLD=''; C_GRN=''; C_YEL=''; C_RED=''; C_DIM=''; C_RST='' +fi + +step() { echo; echo "${C_BOLD}▸ $*${C_RST}"; } +ok() { echo "${C_GRN} ✓${C_RST} $*"; } +warn() { echo "${C_YEL} ⚠${C_RST} $*"; } +die() { echo "${C_RED} ✗ $*${C_RST}" >&2; exit 1; } +note() { echo "${C_DIM} $*${C_RST}"; } + +# ─────────────────────────────────────────────────────────── +# Step 0: Sanity +# ─────────────────────────────────────────────────────────── +step "Sanity checks" + +[ -d .git ] || die "Not in a git repo. cd to your get-shit-done checkout." +command -v gh >/dev/null || die "gh not found. https://cli.github.com/" +gh auth status >/dev/null 2>&1 || die "gh not authenticated." + +REMOTE_URL=$(git remote get-url origin 2>/dev/null || true) +case "$REMOTE_URL" in + *"$REPO"*) ok "Remote: $REMOTE_URL" ;; + *) die "Remote 'origin' is $REMOTE_URL — expected to contain $REPO." ;; +esac + +# Protect rollout files from the stash sweep. +ROLLOUT_STAGE=$(mktemp -d) +ROLLOUT_FILES=(rollout-next-phase1.sh rollout-next-phase2.sh next-branch-files.tar.gz) +for f in "${ROLLOUT_FILES[@]}"; do + [ -f "./$f" ] && mv "./$f" "$ROLLOUT_STAGE/" +done +restore_rollout_files() { + for f in "$ROLLOUT_STAGE"/*; do + [ -e "$f" ] || continue + cp "$f" ./ 2>/dev/null || true + done + rm -rf "$ROLLOUT_STAGE" 2>/dev/null || true +} +trap restore_rollout_files EXIT + +# Stash any in-progress work before switching branches. +CURRENT_BR=$(git rev-parse --abbrev-ref HEAD) +note "Currently on: $CURRENT_BR" +if [ -n "$(git status --porcelain)" ]; then + STASH_MSG="pre-next-phase2-$(date +%Y%m%d-%H%M%S) (from $CURRENT_BR)" + git stash push --include-untracked --message "$STASH_MSG" >/dev/null + ok "Stashed: $STASH_MSG" + STASHED=1 +else + STASHED=0 +fi + +restore_rollout_files + +# ─────────────────────────────────────────────────────────── +# Step 1: Refresh main, ensure Phase 1 is present +# ─────────────────────────────────────────────────────────── +step "Switch to main and pull" + +git fetch origin --quiet +git checkout main >/dev/null +git pull --ff-only origin main >/dev/null +ok "main is current at $(git log -1 --format='%h %s' | head -c 80)" + +# Phase-1 sentinel files must be on main now. +PHASE1_FILES=( + "docs/branching.md" + ".github/workflows/auto-backmerge.yml" + ".github/workflows/pr-target-validator.yml" + "scripts/setup-branch-protection.sh" +) +for f in "${PHASE1_FILES[@]}"; do + [ -f "$f" ] || die "Phase 1 file missing on main: $f. Has the Phase 1 PR merged?" +done +ok "Phase 1 files present on main" + +# ─────────────────────────────────────────────────────────── +# Step 2: Create next branch (idempotent) +# ─────────────────────────────────────────────────────────── +step "Create or verify the next branch" + +if git ls-remote --exit-code origin next >/dev/null 2>&1; then + ok "origin/next already exists — leaving as is" +else + if [ "$DRY_RUN" = "1" ]; then + warn "DRY_RUN=1 — would create and push next from main HEAD" + else + # Create next from current main HEAD locally and push. + git checkout -b next main 2>/dev/null || git checkout next + git push -u origin next + ok "Created and pushed origin/next at $(git log -1 --format='%h')" + # Switch back to main so subsequent steps don't accidentally edit next. + git checkout main >/dev/null + fi +fi + +# ─────────────────────────────────────────────────────────── +# Step 3: Apply branch protection +# ─────────────────────────────────────────────────────────── +step "Apply branch protection to main and next" + +if [ "${SKIP_PROTECTION:-0}" = "1" ]; then + warn "SKIP_PROTECTION=1 — skipping. You will need to run this manually:" + note " bash scripts/setup-branch-protection.sh" +elif [ "$DRY_RUN" = "1" ]; then + warn "DRY_RUN=1 — would run scripts/setup-branch-protection.sh" + REPO="$REPO" DRY_RUN=1 bash scripts/setup-branch-protection.sh | head -40 || true +else + REPO="$REPO" bash scripts/setup-branch-protection.sh + ok "Branch protection applied" +fi + +# ─────────────────────────────────────────────────────────── +# Step 4: Flip default branch to next +# ─────────────────────────────────────────────────────────── +step "Switch default branch to next" + +CURRENT_DEFAULT=$(gh api "/repos/$REPO" --jq '.default_branch') +note "Current default: $CURRENT_DEFAULT" + +if [ "$CURRENT_DEFAULT" = "next" ]; then + ok "Default is already next — skip" +elif [ "${SKIP_DEFAULT_FLIP:-0}" = "1" ]; then + warn "SKIP_DEFAULT_FLIP=1 — leaving default as $CURRENT_DEFAULT" + note "To flip later: Settings → Branches → Default branch → next" +elif [ "$DRY_RUN" = "1" ]; then + warn "DRY_RUN=1 — would PATCH /repos/$REPO default_branch=next" +else + gh api -X PATCH "/repos/$REPO" -f default_branch=next >/dev/null + ok "Default branch switched to next" +fi + +# ─────────────────────────────────────────────────────────── +# Step 5 + 6: Flip the two phase-gate flags on a follow-up branch +# ─────────────────────────────────────────────────────────── +step "Flip phase-gate flags (auto-backmerge.yml + pr-target-validator.yml)" + +# Both edits need to land on a feature branch off `next`, then PR'd back. +# (We can't push directly to main anymore — branch protection forbids it.) + +# Create or switch to the flip branch off next. +git fetch origin next --quiet 2>/dev/null || true +if git show-ref --verify --quiet "refs/heads/$FLIP_BRANCH"; then + git checkout "$FLIP_BRANCH" >/dev/null + if git rev-parse --verify "origin/$FLIP_BRANCH" >/dev/null 2>&1; then + warn "origin/$FLIP_BRANCH already exists — pulling to be sure" + git pull --ff-only origin "$FLIP_BRANCH" >/dev/null || true + else + git reset --hard origin/next >/dev/null + fi +else + git checkout -b "$FLIP_BRANCH" origin/next >/dev/null +fi + +AUTO_BM=".github/workflows/auto-backmerge.yml" +VALID=".github/workflows/pr-target-validator.yml" + +# Flip auto-backmerge: `if: false` (the phase-1 gate line) → `if: true` +# The pattern is anchored by the exact comment + indentation so we don't +# accidentally hit a different `if:` line. +if grep -q " if: false" "$AUTO_BM"; then + # macOS-compatible in-place edit + sed -i.rollout-bak "s/^ if: false$/ if: true/" "$AUTO_BM" + rm -f "$AUTO_BM.rollout-bak" + grep -q " if: true" "$AUTO_BM" || die "auto-backmerge.yml flip didn't take" + ok "auto-backmerge.yml: if: false → if: true" +elif grep -q " if: true" "$AUTO_BM"; then + ok "auto-backmerge.yml already flipped (idempotent skip)" +else + die "Could not find phase-gate 'if:' line in $AUTO_BM" +fi + +# Flip validator: WARN_ONLY: 'true' → 'false' +if grep -q "WARN_ONLY: 'true'" "$VALID"; then + sed -i.rollout-bak "s/WARN_ONLY: 'true'/WARN_ONLY: 'false'/" "$VALID" + rm -f "$VALID.rollout-bak" + grep -q "WARN_ONLY: 'false'" "$VALID" || die "validator flip didn't take" + ok "pr-target-validator.yml: WARN_ONLY 'true' → 'false'" +elif grep -q "WARN_ONLY: 'false'" "$VALID"; then + ok "pr-target-validator.yml already flipped (idempotent skip)" +else + die "Could not find WARN_ONLY in $VALID" +fi + +# YAML validation +for f in "$AUTO_BM" "$VALID"; do + python3 -c "import yaml; yaml.safe_load(open('$f'))" || die "YAML invalid after flip: $f" +done +ok "YAML still valid" + +# Commit +git add "$AUTO_BM" "$VALID" +if git diff --cached --quiet; then + warn "Nothing to commit (already committed)" +else + git commit -m "chore: enable next-branch automation (Phase 2 flips) + +- auto-backmerge.yml: enable the job (was if: false in Phase 1) +- pr-target-validator.yml: enforce instead of warn-only + +These flips are the operational gate for the next-branch model. The +next branch and branch protection were created/applied before this PR." + ok "Committed flips" +fi + +# ─────────────────────────────────────────────────────────── +# Step 7: Push + open follow-up PR against next +# ─────────────────────────────────────────────────────────── +step "Push and open follow-up PR (base = next)" + +if [ "$DRY_RUN" = "1" ]; then + warn "DRY_RUN=1 — skipping push and PR" +else + git push -u origin "$FLIP_BRANCH" + ok "Pushed $FLIP_BRANCH" + + EXISTING_PR=$(gh pr list --repo "$REPO" --head "$FLIP_BRANCH" --state open --json number --jq '.[0].number' 2>/dev/null || echo "") + PR_TITLE="chore: enable next-branch automation (Phase 2 flips)" + PR_BODY="Phase 2 follow-up to the \`next\` integration-branch rollout. + +This PR flips the two phase-gate flags that shipped inert in Phase 1: + +- \`auto-backmerge.yml\`: \`if: false\` → \`if: true\` (the back-merge job now runs on every push to main) +- \`pr-target-validator.yml\`: \`WARN_ONLY: 'true'\` → \`'false'\` (the validator now fails the check instead of just commenting) + +The \`next\` branch and branch-protection rules were created/applied out-of-band by \`scripts/rollout-next-phase2.sh\` before this PR. + +After this merges, the model is fully live. New PRs should target \`next\` (it's the default now); the validator will catch mistakes and tell contributors how to retarget." + + if [ -n "$EXISTING_PR" ]; then + echo "$PR_BODY" | gh pr edit "$EXISTING_PR" --repo "$REPO" --title "$PR_TITLE" --body-file - + ok "Updated existing PR #$EXISTING_PR" + else + PR_URL=$(echo "$PR_BODY" | gh pr create \ + --repo "$REPO" \ + --base next \ + --head "$FLIP_BRANCH" \ + --title "$PR_TITLE" \ + --body-file -) + ok "Opened PR: $PR_URL" + fi +fi + +# ─────────────────────────────────────────────────────────── +# Done +# ─────────────────────────────────────────────────────────── +echo +echo "${C_BOLD}${C_GRN}━━━ Phase 2 complete ━━━${C_RST}" +echo +echo "What's live now:" +echo " • next branch exists, branch protection applied to main + next" +echo " • Default branch: next (new PRs default to it)" +echo " • auto-backmerge.yml: enabled — will open main→next PR after each push to main" +echo " • pr-target-validator.yml: enforcing — fails the check on wrong target" +echo +echo "Follow-up PR to merge: $(gh pr list --repo "$REPO" --head "$FLIP_BRANCH" --state open --json url --jq '.[0].url' 2>/dev/null || echo "(check gh pr list)")" +echo +echo "Phase 3 (when ready, weeks-to-months out): apply the release.yml /" +echo "hotfix.yml / auto-branch.yml patches inlined in the ADR." +if [ "$STASHED" = "1" ]; then + echo + echo "Your earlier work is stashed. Recover it with:" + echo " git checkout $CURRENT_BR && git stash list && git stash pop stash@{0}" +fi diff --git a/scripts/setup-branch-protection.sh b/scripts/setup-branch-protection.sh new file mode 100755 index 000000000..1e6ee6d5c --- /dev/null +++ b/scripts/setup-branch-protection.sh @@ -0,0 +1,236 @@ +#!/usr/bin/env bash +# setup-branch-protection.sh +# +# Apply branch protection rules to `main` and `next` for the GSD repo. +# Idempotent — run as many times as you like. Re-running brings the live +# rules back to what this script declares, so the script IS the source of +# truth for branch protection. +# +# Usage: +# bash scripts/setup-branch-protection.sh # apply both +# bash scripts/setup-branch-protection.sh main # apply only main +# bash scripts/setup-branch-protection.sh next # apply only next +# DRY_RUN=1 bash scripts/setup-branch-protection.sh # show payloads, don't apply +# +# Requirements: +# - gh CLI authenticated against open-gsd/get-shit-done-redux with admin scope +# - jq installed +# +# What it sets: +# +# main (strict — production): +# - 2 required approving reviews +# - dismiss stale reviews on push +# - require code-owner review when CODEOWNERS applies +# - all required status checks must pass (defined in REQUIRED_CHECKS_MAIN below) +# - require branches to be up to date before merging (ON — `main` is production) +# - require linear history (OFF — release back-merges use merge commits) +# - require conversation resolution +# - require signed commits +# - block force-push and deletion +# - admins included +# +# next (loose — integration): +# - 1 required approving review +# - dismiss stale reviews on push +# - require code-owner review when CODEOWNERS applies +# - all required status checks must pass (defined in REQUIRED_CHECKS_NEXT below) +# - require branches to be up to date before merging (OFF — this is the whole point) +# - require linear history (OFF — auto-backmerge from main needs merge commits +# to preserve the link from next's history to main's release tags; +# feature PRs still squash-merge by repo merge-strategy setting) +# - require conversation resolution +# - require signed commits (OFF on next — easier for contributors) +# - block force-push and deletion +# - admins included +# +# See: docs/adr/XXXX-introduce-next-integration-branch.md +# See: docs/branching.md + +set -euo pipefail + +REPO="${REPO:-open-gsd/get-shit-done-redux}" +DRY_RUN="${DRY_RUN:-0}" + +# Required status checks. Adjust as your CI suite evolves. +# The names must match the JOB NAME (not the workflow name) that GitHub +# records — check existing PRs to confirm. +REQUIRED_CHECKS_MAIN=( + "test" + "install-smoke" + "security-scan" + "Changeset Required / changeset-lint" + "Docs Required / docs-lint" + "PR Gate / size-check" + "Validate Branch Name / check-branch" +) + +REQUIRED_CHECKS_NEXT=( + "test" + "PR Gate / size-check" + "Validate Branch Name / check-branch" + "Changeset Required / changeset-lint" + "Docs Required / docs-lint" + "PR Target Validator / validate-target" +) + +require_cmd() { + command -v "$1" >/dev/null 2>&1 || { + echo "ERROR: missing required command: $1" >&2 + exit 1 + } +} + +require_cmd gh +require_cmd jq + +verify_auth() { + if ! gh auth status >/dev/null 2>&1; then + echo "ERROR: gh CLI is not authenticated. Run 'gh auth login' first." >&2 + exit 1 + fi +} + +build_payload() { + local branch="$1" + shift + local checks_array=("$@") + + # Branch-specific knobs. + local approvals require_up_to_date linear_history signed_commits + case "$branch" in + main) + approvals=2 + require_up_to_date=true + linear_history=false + signed_commits=true + ;; + next) + approvals=1 + require_up_to_date=false + # linear_history=false: auto-backmerge from main needs merge commits to + # preserve the link to release tags. Feature PRs still produce one + # commit each via repo-level "squash and merge" default — that gives + # us a clean log without enforcing linearity at the protection layer. + linear_history=false + signed_commits=false + ;; + *) + echo "ERROR: unknown branch '$branch'" >&2 + exit 1 + ;; + esac + + # Build the contexts array via jq for safe quoting. + local contexts_json + contexts_json=$(printf '%s\n' "${checks_array[@]}" | jq -R . | jq -s .) + + jq -n \ + --argjson contexts "$contexts_json" \ + --argjson approvals "$approvals" \ + --argjson require_up_to_date "$require_up_to_date" \ + --argjson linear_history "$linear_history" \ + --argjson signed_commits "$signed_commits" \ + '{ + required_status_checks: { + strict: $require_up_to_date, + contexts: $contexts + }, + enforce_admins: true, + required_pull_request_reviews: { + dismiss_stale_reviews: true, + require_code_owner_reviews: true, + required_approving_review_count: $approvals, + require_last_push_approval: false + }, + restrictions: null, + required_linear_history: $linear_history, + allow_force_pushes: false, + allow_deletions: false, + required_conversation_resolution: true, + required_signatures: $signed_commits, + lock_branch: false, + allow_fork_syncing: true + }' +} + +apply_protection() { + local branch="$1" + local checks_var_name + if [ "$branch" = "main" ]; then + checks_var_name="REQUIRED_CHECKS_MAIN" + else + checks_var_name="REQUIRED_CHECKS_NEXT" + fi + + # Expand the array indirectly (bash 3 compatible — macOS default). + eval "local checks=(\"\${${checks_var_name}[@]}\")" + + local payload + payload=$(build_payload "$branch" "${checks[@]}") + + echo "──────────────────────────────────────────" + echo "Branch: $branch" + echo "Required checks (${#checks[@]}):" + printf ' - %s\n' "${checks[@]}" + echo "──────────────────────────────────────────" + + if [ "$DRY_RUN" = "1" ]; then + echo "[DRY RUN] Would PUT to /repos/${REPO}/branches/${branch}/protection:" + echo "$payload" | jq . + return 0 + fi + + echo "Applying branch protection..." + echo "$payload" | gh api \ + -X PUT \ + -H "Accept: application/vnd.github+json" \ + -H "X-GitHub-Api-Version: 2022-11-28" \ + --input - \ + "/repos/${REPO}/branches/${branch}/protection" \ + >/dev/null + echo "✓ Protection rules applied to $branch." +} + +ensure_branch_exists() { + local branch="$1" + if ! gh api "/repos/${REPO}/branches/${branch}" >/dev/null 2>&1; then + echo "ERROR: branch '$branch' does not exist in $REPO." >&2 + if [ "$branch" = "next" ]; then + cat <&2 + +Create the next branch first: + git checkout main && git pull --ff-only + git checkout -b next && git push -u origin next + +Then re-run this script. +EOF + fi + exit 1 + fi +} + +main() { + verify_auth + + local targets=() + if [ $# -eq 0 ]; then + targets=(main next) + else + targets=("$@") + fi + + for branch in "${targets[@]}"; do + if [ "$branch" != "main" ] && [ "$branch" != "next" ]; then + echo "ERROR: unsupported branch '$branch'. Use 'main' or 'next'." >&2 + exit 1 + fi + ensure_branch_exists "$branch" + apply_protection "$branch" + done + + echo "" + echo "Done. To verify: gh api /repos/${REPO}/branches//protection | jq ." +} + +main "$@"