diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index 4212954429..e6b4da4993 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -463,7 +463,12 @@ Conventional commits drive semantic-release. `feat:` triggers a minor bump, `fix **Default branch for PRs is `dev`.** Feature work, fixes, and refactors all land there via normal PRs. `main` is updated only through the release workflows — never PR a feature branch directly into `main`. -**Branch lineage invariant:** `main` is always an ancestor of `dev`, and `dev` is always an ancestor of `main` after a release reconciles. The `Release: Semantic Version` workflow's `ff_target` promotion mode and the auto dev-FF-reconcile keep this invariant — do not break it by force-pushing or merging shared branches manually. +**Branch lineage invariant:** after every release reconciles, `main` is an ancestor of `dev`, so every tag on `main` is reachable from `dev`. The `Release: Semantic Version` workflow keeps this invariant in two ways depending on the promotion source: + +- **dev → main promotion** (minor/major): main fast-forwards to the dev SHA, semantic-release appends a `chore(release):` commit on top, then dev fast-forwards to absorb that commit. No history rewrites on either branch. +- **hotfix-staging → main promotion** (current-line patch): main fast-forwards to the hotfix-staging SHA, semantic-release appends the `chore(release):` commit, then dev is **rebase-reconciled** onto the new main. `git rebase` drops dev's originals of the cherry-picked fixes (patch-id match) and replays any unique dev work on top. This is the only place the workflow force-pushes (`--force-with-lease`) — it is intentional and load-bearing. + +After a hotfix release, open PRs targeting `dev` are auto-rebased by the `Auto-rebase open PRs` workflow (a thin wrapper around `peter-evans/rebase@v3`). PRs from forks need "Allow edits by maintainers" enabled or the action silently skips them; drafts and PRs labeled `no-auto-rebase` are also excluded. The workflow's job summary reports the rebased count and lists the buckets PRs can fall into; conflict-skipped PRs need a manual `git rebase origin/dev` by the author. **Patch flow (current line _or_ older line, same staging mechanism):** @@ -482,7 +487,7 @@ Conventional commits drive semantic-release. `feat:` triggers a minor bump, `fix **Things agents should not do without explicit user direction:** -- Force-push or rebase `main`, `dev`, or any `hotfix/*` branch. +- Force-push or rebase `main`, `dev`, or any `hotfix/*` branch. (The release workflow's rebase-reconcile of `dev` after a hotfix-staging promotion is the one sanctioned exception; humans should not replicate it manually unless the workflow's remediation block explicitly instructs them to.) - Manually create tags matching `v*` (semantic-release owns these). - Bump `CMakeLists.txt`'s `VERSION` field outside the release workflow. - PR a feature branch directly into `main`. diff --git a/.github/workflows/auto-rebase-prs.yaml b/.github/workflows/auto-rebase-prs.yaml new file mode 100644 index 0000000000..e6dc3e9920 --- /dev/null +++ b/.github/workflows/auto-rebase-prs.yaml @@ -0,0 +1,109 @@ +name: "Auto-rebase open PRs" + +# Rebase open PRs against `dev` whenever it moves. Thin wrapper over +# peter-evans/rebase@v3. +# +# Forks: pushing to a fork PR head requires (a) a user PAT belonging to +# a maintainer of this repo and (b) the PR author having "Allow edits +# by maintainers" checked. The action silently skips PRs missing either, +# and skips conflicts. +# +# Token: RELEASE_PAT (classic PAT, `repo` + `workflow`). `workflow` is +# required because rebased PRs may include `.github/workflows/` diffs. + +on: + push: + branches: [dev] + workflow_dispatch: + inputs: + pr_number: + description: "Optional: rebase just this PR number (the action accepts head as `:`; we resolve via gh api). Leave empty to rebase all open PRs against dev." + required: false + default: "" + +permissions: + contents: write + pull-requests: write + +concurrency: + # Serialize so two back-to-back pushes to dev don't race and produce + # interleaved force-pushes on the same PR. + group: auto-rebase-prs + cancel-in-progress: false + +jobs: + rebase: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + fetch-depth: 0 + token: ${{ secrets.RELEASE_PAT }} + + - name: Resolve single-PR head spec (if pr_number given) + id: resolve_head + if: inputs.pr_number != '' + env: + GH_TOKEN: ${{ secrets.RELEASE_PAT }} + PR_NUMBER: ${{ inputs.pr_number }} + run: | + set -euo pipefail + # Guard against rebasing a closed PR or one targeting + # a non-dev base (e.g. a hotfix staging branch). + PR_JSON=$(gh pr view "${PR_NUMBER}" --repo '${{ github.repository }}' \ + --json state,baseRefName,headRepositoryOwner,headRefName) + PR_STATE=$(echo "${PR_JSON}" | jq -r '.state') + PR_BASE=$(echo "${PR_JSON}" | jq -r '.baseRefName') + if [[ "${PR_STATE}" != "OPEN" ]]; then + echo "::warning::PR #${PR_NUMBER} is ${PR_STATE}, not OPEN — skipping." + echo "head=" >> "$GITHUB_OUTPUT" + exit 0 + fi + if [[ "${PR_BASE}" != "dev" ]]; then + echo "::warning::PR #${PR_NUMBER} targets '${PR_BASE}', not 'dev' — skipping to avoid rebasing the wrong base." + echo "head=" >> "$GITHUB_OUTPUT" + exit 0 + fi + HEAD_REF=$(echo "${PR_JSON}" | jq -r '"\(.headRepositoryOwner.login):\(.headRefName)"') + echo "head=${HEAD_REF}" >> "$GITHUB_OUTPUT" + + - name: Rebase open PRs against dev + id: rebase + # Single-PR dispatches without a valid head fall through to + # the all-PRs default; this guard keeps that from happening. + if: inputs.pr_number == '' || steps.resolve_head.outputs.head != '' + uses: peter-evans/rebase@v3 + with: + token: ${{ secrets.RELEASE_PAT }} + base: dev + head: ${{ steps.resolve_head.outputs.head }} + exclude-drafts: true + exclude-labels: | + no-auto-rebase + + - name: Summarize + env: + REBASED: ${{ steps.rebase.outputs.rebased-count }} + run: | + { + echo "## Auto-rebase summary" + echo "" + echo "- PRs rebased: \`${REBASED:-0}\`" + echo "- Base: \`dev\`" + if [[ -n '${{ inputs.pr_number }}' ]]; then + echo "- Targeted single PR: #${{ inputs.pr_number }} (head \`${{ steps.resolve_head.outputs.head }}\`)" + fi + echo "" + echo "PRs not rebased fall into one of three buckets:" + echo "- Already up-to-date with \`dev\` (no-op)" + echo "- Draft or labeled \`no-auto-rebase\` (excluded)" + echo "- Conflict during rebase, OR fork without maintainer-edit access (action silently skips)" + echo "" + echo "PRs in the conflict bucket need a manual rebase by the author:" + echo '```bash' + echo "git fetch origin && git rebase origin/dev" + echo "# resolve conflicts, git rebase --continue" + echo "git push --force-with-lease" + echo '```' + } >> "$GITHUB_STEP_SUMMARY" diff --git a/.github/workflows/release-semantic.yaml b/.github/workflows/release-semantic.yaml index ccc18240e6..57f1551780 100644 --- a/.github/workflows/release-semantic.yaml +++ b/.github/workflows/release-semantic.yaml @@ -1,16 +1,13 @@ name: "Release: Semantic Version" -# Dispatch on: -# - dev → cuts vX.Y.Z-rc.N (RC channel) -# - main → cuts vX.Y.Z stable (current line) -# - hotfix/X.Y.x → cuts vX.Y.Z+1 on the X.Y maintenance channel -# (only valid when main has shipped a higher minor/major) +# Dispatch on: dev (RC) | main (stable, current line) | hotfix/X.Y.x +# (older line, only valid when main has shipped a newer minor/major). # -# Promotion mode (dev → main): -# Dispatch on main with `ff_target` set to a dev SHA. The workflow -# fast-forwards main to that SHA, runs semantic-release to cut stable, -# then fast-forwards dev to absorb the chore(release) commit. One click, -# no PR, no merge commits, no force pushes. +# Promotion (ff_target on main): FF main to ff_target, run semantic- +# release, then reconcile dev. Dev-source reconciles FF-only; hotfix- +# staging source rebases dev onto the new main so the release tag +# enters dev's ancestry — the only force-push this workflow performs. +# `auto-rebase-prs.yaml` then rebases open PRs targeting dev. on: workflow_dispatch: @@ -71,9 +68,13 @@ jobs: echo "::error::main is not an ancestor of ff_target ${FF_TARGET} — fast-forward is not possible." exit 1 fi - # Identify the source branch for the promotion. dev → reconcile - # dev afterward; hotfix/* → patch-id dedup handles it at the - # next dev→main promotion, no reconcile. + # Identify the source branch for the promotion. + # dev source → FF-reconcile dev afterward (no rewrite). + # hotfix-staging → rebase-reconcile dev afterward so the + # release commit + tag enter dev's + # ancestry. Force-with-lease push; `git + # rebase` drops patch-id duplicates of + # the cherry-picks already on main. SOURCE="" if git merge-base --is-ancestor "${FF_TARGET}" origin/dev; then SOURCE="dev" @@ -153,17 +154,19 @@ jobs: # to dev during the release, exit with a warning instead of # rewriting their work. if ! git merge-base --is-ancestor origin/dev origin/main; then - echo "::warning::dev moved during promotion; FF-reconcile skipped." { echo "" - echo "## Dev reconcile skipped" + echo "## ⚠️ Dev reconcile skipped — dev moved during promotion" echo "Dev was pushed to during the release. To reconcile manually:" echo "" echo '```bash' echo "git fetch && git checkout dev && git merge --ff-only origin/main && git push" echo '```' } >> "$GITHUB_STEP_SUMMARY" - exit 0 + # Fail loud: tag is on main but dev isn't reconciled + # (same invariant break as the hotfix-staging path). + echo "::error::dev moved during promotion — dev is unreconciled. See job summary for manual remediation." + exit 1 fi NEW_MAIN=$(git rev-parse origin/main) REMOTE="https://x-access-token:${TOKEN}@github.com/${{ github.repository }}.git" @@ -171,12 +174,72 @@ jobs: git push --force-with-lease="dev:${CURRENT_DEV}" "${REMOTE}" "${NEW_MAIN}:refs/heads/dev" echo "::notice::Reconciled dev to main ($NEW_MAIN)." - - name: Note dev reconcile skipped (hotfix-staging source) + - name: Rebase-reconcile dev with release commit (promotion mode, hotfix-staging source) if: ${{ inputs.ff_target != '' && steps.semantic.outputs.new_release_published == 'true' && steps.validate.outputs.source != 'dev' && steps.validate.outputs.source != '' }} + env: + TOKEN: ${{ secrets.RELEASE_PAT }} run: | + set -euo pipefail + git fetch origin main dev + NEW_MAIN=$(git rev-parse origin/main) + CURRENT_DEV=$(git rev-parse origin/dev) + + # FF-only won't work: dev and main diverged. Rebase + # preserves linear history; patch-id dedup drops the + # cherry-picked-fix commits automatically. + git checkout -B dev "${CURRENT_DEV}" + if ! git rebase "${NEW_MAIN}"; then + git rebase --abort || true + { + echo "" + echo "## ⚠️ Dev reconcile FAILED — manual intervention required" + echo "" + echo "Rebasing \`dev\` onto the new \`main\` (\`$(git rev-parse --short=9 "${NEW_MAIN}")\`) hit a conflict." + echo "The hotfix release is live on \`main\`, but \`dev\` has NOT absorbed it." + echo "Until reconciled, the next RC cut on \`dev\` will compute the wrong version floor." + echo "" + echo "**Recover by running locally:**" + echo "" + echo '```bash' + echo "git fetch origin" + echo "git checkout dev && git reset --hard origin/dev" + echo "git rebase origin/main # resolve conflicts, git rebase --continue" + echo "git push --force-with-lease origin dev" + echo '```' + } >> "$GITHUB_STEP_SUMMARY" + echo "::error::Dev rebase-reconcile failed — see job summary for remediation." + exit 1 + fi + + REMOTE="https://x-access-token:${TOKEN}@github.com/${{ github.repository }}.git" + # --force-with-lease guards against a concurrent push to + # dev: if someone landed a commit during the release, + # abort instead of overwriting their work. + if ! git push --force-with-lease="dev:${CURRENT_DEV}" "${REMOTE}" "HEAD:refs/heads/dev"; then + { + echo "" + echo "## ⚠️ Dev push rejected (lease check failed)" + echo "" + echo "Dev was pushed to during the release. Reconcile manually:" + echo "" + echo '```bash' + echo "git fetch origin && git checkout dev && git reset --hard origin/dev" + echo "git rebase origin/main && git push --force-with-lease origin dev" + echo '```' + } >> "$GITHUB_STEP_SUMMARY" + # Fail loud: tag is on main but dev's history doesn't + # contain it, so the next RC would compute its floor + # and changelog against the wrong prior tag. + echo "::error::Dev moved during release — dev is unreconciled. See job summary for manual remediation." + exit 1 + fi + { echo "" - echo "## Dev reconcile not applicable" - echo "Promotion source was \`${{ steps.validate.outputs.source }}\` (hotfix staging branch). The cherry-picked commits on main have different SHAs than their originals on dev — semantic-release's patch-id-dedup will collapse the duplicates at the next dev→main promotion, so no FF-reconcile is needed (and would not be possible)." + echo "## Dev reconciled (rebase-forward)" + echo "- Source: hotfix-staging \`${{ steps.validate.outputs.source }}\`" + echo "- New main: \`$(git rev-parse --short=9 "${NEW_MAIN}")\`" + echo "- Dev rebased linearly onto new main; identical-patch commits auto-dropped by \`git rebase\`." + echo "- Open PRs against dev are auto-rebased by the \`Auto-rebase open PRs\` workflow." } >> "$GITHUB_STEP_SUMMARY" - echo "::notice::Hotfix-staging promotion — dev reconcile not applicable." + echo "::notice::Reconciled dev onto main via rebase (linear history preserved)."