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 000000000..6d4a90728 Binary files /dev/null and b/next-branch-files.tar.gz differ 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 "$@"