Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 7 additions & 2 deletions .claude/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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):**

Expand All @@ -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`.
Expand Down
109 changes: 109 additions & 0 deletions .github/workflows/auto-rebase-prs.yaml
Original file line number Diff line number Diff line change
@@ -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 `<owner>:<branch>`; 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"
103 changes: 83 additions & 20 deletions .github/workflows/release-semantic.yaml
Original file line number Diff line number Diff line change
@@ -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:
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -153,30 +154,92 @@ 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"
CURRENT_DEV=$(git rev-parse origin/dev)
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
Comment thread
coderabbitai[bot] marked this conversation as resolved.

{
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)."
Loading