From dd4497080e42f6f1a6ecb5c658a95526d88cbd7d Mon Sep 17 00:00:00 2001 From: Alan Tse Date: Mon, 18 May 2026 18:51:09 -0700 Subject: [PATCH 1/6] ci: reconcile dev after hotfix-staging releases MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The release-semantic.yaml `ff_target` flow skipped its dev reconcile when the promotion source was a hotfix-staging branch, on the (wrong) assumption that patch-id-dedup would collapse the duplicates at the next dev→main promotion. The `chore(release):` commit and its tag have no patch-id equivalent on dev, so they never entered dev's ancestry. The lineage invariant "main is an ancestor of dev after reconcile" silently broke, and the next RC cut on dev (e.g. v1.6.0-rc.1) computed its version floor and changelog against the wrong prior tag — the 1.5.2 hotfix was invisible. Fix the reconcile by rebasing dev onto the new main after a hotfix release. `git rebase` automatically drops dev's originals of the cherry-picked fixes (patch-id match) and replays any unique dev work on top, keeping linear history. Push is `--force-with-lease`; lease failure surfaces a remediation block instead of clobbering concurrent dev pushes. Rebase conflicts fail the job with a recovery script. Add an `Auto-rebase open PRs` workflow that runs on every push to dev (including the rebase-forward push from this fix) and rebases open PRs in-place. Drafts and PRs labeled `no-auto-rebase` are skipped; forks without "Allow edits by maintainers" are listed in the summary; conflicting PRs get a `needs-rebase` label and a comment. Document both flows and the one sanctioned force-push in CLAUDE.md. The new workflow reuses the existing RELEASE_PAT secret rather than introducing a separate token. RELEASE_PAT needs `repo` + `workflow` scopes (classic PAT): `repo` for protected-branch push and PR-head push via maintainer-edit; `workflow` so rebased PRs that include .github/workflows/ changes can be force-pushed without GitHub's "refusing to allow a Personal Access Token to create or update workflow" rejection. --- .claude/CLAUDE.md | 9 +- .github/workflows/auto-rebase-prs.yaml | 218 ++++++++++++++++++++++++ .github/workflows/release-semantic.yaml | 90 +++++++++- 3 files changed, 308 insertions(+), 9 deletions(-) create mode 100644 .github/workflows/auto-rebase-prs.yaml diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index 4212954429..1de437a36b 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. PRs from forks need "Allow edits by maintainers" enabled; the workflow summary lists any that were skipped for missing access. Conflicting PRs get a `needs-rebase` label and a comment. **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..85bfefc6b0 --- /dev/null +++ b/.github/workflows/auto-rebase-prs.yaml @@ -0,0 +1,218 @@ +name: "Auto-rebase open PRs" + +# Rebase open PRs against dev whenever dev moves. Triggered by: +# - Pushes to dev (including the rebase-forward push from +# release-semantic.yaml after a hotfix release). +# - Manual dispatch (recovery after a missed run). +# +# Behavior per PR targeting dev: +# - Skip drafts and PRs labeled `no-auto-rebase`. +# - `git rebase origin/dev` the PR head branch. +# - On clean rebase: force-with-lease push, GitHub recomputes PR diff, +# PR checks re-run on the new head SHA. +# - On conflict: abort, leave a comment, apply `needs-rebase` label. +# +# Permissions / forks: pushing to a PR head branch on a fork requires a +# user PAT belonging to a maintainer of this repo AND the PR author to +# have left "Allow edits by maintainers" checked. PRs that don't meet +# both conditions are skipped and logged in the workflow summary so a +# maintainer can ping the author. PRs whose head branch lives in this +# repo (collaborators) work unconditionally. +# +# Token: RELEASE_PAT is reused here (same scopes the release workflows +# need). Required PAT scopes (classic): `repo` + `workflow`. The +# `workflow` scope is needed because a rebased PR may include +# `.github/workflows/` changes; force-pushing those without it would +# fail with "refusing to allow a Personal Access Token to create or +# update workflow". + +on: + push: + branches: [dev] + workflow_dispatch: + inputs: + pr_number: + description: "Optional: rebase just this PR number. Leave empty to rebase all." + 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 (for git CLI) + uses: actions/checkout@v6 + with: + fetch-depth: 0 + persist-credentials: false + token: ${{ secrets.RELEASE_PAT }} + + - name: Configure git identity + run: | + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + + - name: Rebase open PRs against dev + env: + GH_TOKEN: ${{ secrets.RELEASE_PAT }} + ONLY_PR: ${{ inputs.pr_number }} + run: | + set -euo pipefail + + # Best-effort: ensure the needs-rebase label exists. + gh label create needs-rebase \ + --repo '${{ github.repository }}' \ + --color d93f0b \ + --description "Auto-rebase failed due to merge conflicts." 2>/dev/null || true + + git fetch origin dev:refs/remotes/origin/dev + + if [[ -n "${ONLY_PR}" ]]; then + PR_JSON=$(gh pr view "${ONLY_PR}" --repo '${{ github.repository }}' \ + --json number,isDraft,headRefName,headRepository,headRepositoryOwner,maintainerCanModify,labels,state) + PRS=$(echo "${PR_JSON}" | jq -c '[.]') + else + PRS=$(gh pr list --repo '${{ github.repository }}' --base dev --state open \ + --json number,isDraft,headRefName,headRepository,headRepositoryOwner,maintainerCanModify,labels) + fi + + : > /tmp/summary.md + REBASED=0; SKIPPED=0; CONFLICTS=0; UNPUSHABLE=0 + declare -a UNPUSHABLE_LIST + + for row in $(echo "${PRS}" | jq -r '.[] | @base64'); do + _jq() { echo "${row}" | base64 -d | jq -r "$1"; } + NUM=$(_jq '.number') + DRAFT=$(_jq '.isDraft') + HEAD_REF=$(_jq '.headRefName') + HEAD_REPO=$(_jq '.headRepository.name // empty') + HEAD_OWNER=$(_jq '.headRepositoryOwner.login // empty') + MAINTAINER_CAN_MODIFY=$(_jq '.maintainerCanModify') + LABELS=$(_jq '[.labels[].name] | join(",")') + + if [[ "${DRAFT}" == "true" ]]; then + echo "- #${NUM}: skipped (draft)" >> /tmp/summary.md + SKIPPED=$((SKIPPED+1)); continue + fi + if [[ ",${LABELS}," == *",no-auto-rebase,"* ]]; then + echo "- #${NUM}: skipped (no-auto-rebase label)" >> /tmp/summary.md + SKIPPED=$((SKIPPED+1)); continue + fi + + SAME_REPO_OWNER='${{ github.repository_owner }}' + IS_FORK="true" + if [[ "${HEAD_OWNER}" == "${SAME_REPO_OWNER}" ]]; then + IS_FORK="false" + fi + if [[ "${IS_FORK}" == "true" && "${MAINTAINER_CAN_MODIFY}" != "true" ]]; then + echo "- #${NUM}: skipped (fork without maintainer-edit access — ask author to enable)" >> /tmp/summary.md + UNPUSHABLE=$((UNPUSHABLE+1)) + UNPUSHABLE_LIST+=("#${NUM}") + continue + fi + + # Build the remote URL for the PR head repo. For + # same-repo PRs HEAD_OWNER == repo owner. For forks + # we use the fork's owner/name. The PAT must have + # write access to both. + PR_REMOTE="https://x-access-token:${GH_TOKEN}@github.com/${HEAD_OWNER}/${HEAD_REPO}.git" + + # Fetch the PR head into a detached local ref. Use a + # unique local ref to avoid clobbering across loop + # iterations. + LOCAL_REF="refs/auto-rebase/pr-${NUM}" + if ! git fetch "${PR_REMOTE}" "+refs/heads/${HEAD_REF}:${LOCAL_REF}" 2>/tmp/fetch.err; then + echo "- #${NUM}: skipped (could not fetch head — see fetch error)" >> /tmp/summary.md + cat /tmp/fetch.err >&2 + SKIPPED=$((SKIPPED+1)); continue + fi + + HEAD_SHA=$(git rev-parse "${LOCAL_REF}") + + # Fast-path: PR head already contains origin/dev. + if git merge-base --is-ancestor origin/dev "${HEAD_SHA}"; then + echo "- #${NUM}: already up-to-date with dev" >> /tmp/summary.md + SKIPPED=$((SKIPPED+1)); continue + fi + + # Attempt the rebase in a worktree to keep the main + # checkout clean. + WORKTREE="/tmp/wt-pr-${NUM}" + git worktree add --detach "${WORKTREE}" "${HEAD_SHA}" >/dev/null + REBASE_OK=true + (cd "${WORKTREE}" && git rebase origin/dev) || REBASE_OK=false + + if [[ "${REBASE_OK}" != "true" ]]; then + (cd "${WORKTREE}" && git rebase --abort) || true + git worktree remove --force "${WORKTREE}" || true + echo "- #${NUM}: ⚠️ conflict during rebase" >> /tmp/summary.md + CONFLICTS=$((CONFLICTS+1)) + gh pr edit "${NUM}" --repo '${{ github.repository }}' --add-label needs-rebase || true + cat > /tmp/conflict-comment.md <<'COMMENT_EOF' + 🤖 Auto-rebase failed: `dev` moved and this PR has conflicts. Please rebase manually: + + ```bash + git fetch origin + git rebase origin/dev + # resolve conflicts, git rebase --continue + git push --force-with-lease + ``` + + I'll remove the `needs-rebase` label automatically when you push the rebased branch. + COMMENT_EOF + # Strip the YAML-required leading indent from the heredoc so + # the comment renders correctly on the PR. + sed -i 's/^ //' /tmp/conflict-comment.md + gh pr comment "${NUM}" --repo '${{ github.repository }}' \ + --body-file /tmp/conflict-comment.md || true + continue + fi + + NEW_SHA=$(cd "${WORKTREE}" && git rev-parse HEAD) + if [[ "${NEW_SHA}" == "${HEAD_SHA}" ]]; then + git worktree remove --force "${WORKTREE}" || true + echo "- #${NUM}: no-op rebase (already linear on dev)" >> /tmp/summary.md + SKIPPED=$((SKIPPED+1)); continue + fi + + if (cd "${WORKTREE}" && git push --force-with-lease="${HEAD_REF}:${HEAD_SHA}" \ + "${PR_REMOTE}" "HEAD:refs/heads/${HEAD_REF}"); then + REBASED=$((REBASED+1)) + echo "- #${NUM}: rebased ${HEAD_SHA:0:9} → ${NEW_SHA:0:9}" >> /tmp/summary.md + # Remove needs-rebase label if present (post-recovery). + gh pr edit "${NUM}" --repo '${{ github.repository }}' --remove-label needs-rebase 2>/dev/null || true + else + echo "- #${NUM}: ⚠️ push rejected (lease check failed — head moved during rebase)" >> /tmp/summary.md + SKIPPED=$((SKIPPED+1)) + fi + git worktree remove --force "${WORKTREE}" || true + done + + { + echo "## Auto-rebase summary" + echo "" + echo "| | |" + echo "|-|-|" + echo "| Rebased and pushed | ${REBASED} |" + echo "| Skipped (up-to-date / draft / labeled / no-op) | ${SKIPPED} |" + echo "| Conflicts (needs-rebase applied) | ${CONFLICTS} |" + echo "| Skipped — fork without maintainer-edit access | ${UNPUSHABLE} |" + if (( UNPUSHABLE > 0 )); then + echo "" + echo "### Forks needing maintainer-edit" + for p in "${UNPUSHABLE_LIST[@]}"; do echo "- ${p}"; done + fi + echo "" + echo "### Per-PR results" + cat /tmp/summary.md + } >> "$GITHUB_STEP_SUMMARY" diff --git a/.github/workflows/release-semantic.yaml b/.github/workflows/release-semantic.yaml index ccc18240e6..016c22b35c 100644 --- a/.github/workflows/release-semantic.yaml +++ b/.github/workflows/release-semantic.yaml @@ -11,6 +11,17 @@ name: "Release: Semantic Version" # 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 mode (hotfix-staging → main, current-line patch): +# Dispatch on main with `ff_target` set to a hotfix-staging SHA. The +# workflow fast-forwards main, cuts the patch release, then +# rebase-reconciles dev onto the new main so the release commit (and +# tag) enter dev's ancestry. `git rebase` drops commits whose patch-id +# already matches an upstream cherry-pick, so dev's history stays +# linear and free of duplicates. This requires a force-with-lease push +# to dev (the only force-push this workflow performs); see the +# `auto-rebase-prs.yaml` workflow which auto-rebases open PRs targeting +# dev after this happens. on: workflow_dispatch: @@ -71,9 +82,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" @@ -171,12 +186,73 @@ 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) + + # A plain FF-reconcile won't work here: dev is not an + # ancestor of new main (they share an older common + # ancestor; main now has the hotfix cherry-picks + release + # chore commit, dev has its own forward progress). Rebase + # dev onto new main to preserve linear history. `git + # rebase` automatically drops commits whose patch-id + # matches an upstream commit (the cherry-picks). + 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" + echo "::warning::Dev moved during release — manual reconcile required." + exit 0 + 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)." From 9c01a4b98df1a557eb0f2ef5f673bd1344e82553 Mon Sep 17 00:00:00 2001 From: Alan Tse Date: Mon, 18 May 2026 20:09:51 -0700 Subject: [PATCH 2/6] ci: use peter-evans/rebase@v3 for auto-rebase MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Switch the auto-rebase-PRs workflow from the hand-rolled bash loop to peter-evans/rebase@v3. The original choice mixed up Option 1 (off-the- shelf action) and Option 2 (custom workflow with conflict labeling and fork-permission reporting) from the design discussion — Option 1 was the intent. Drops: - ~150 lines of custom shell that iterated PRs, ran rebase in a worktree per PR, force-pushed with --force-with-lease, applied `needs-rebase` labels, posted PR comments on conflict. - The `no-auto-rebase` label exclusion is preserved via the action's `exclude-labels` input. - Drafts are still excluded (`exclude-drafts: true`). - Single-PR mode is preserved by resolving `pr_number` to the `:` head spec the action accepts. Behavior trade-offs (intentional with the simpler path): - Conflict-skipped PRs no longer get an auto-applied `needs-rebase` label or an explanatory comment — the action just skips them. The workflow summary documents this bucket so reviewers know what's happening. - Fork PRs without "Allow edits by maintainers" are silently skipped (same as before, just not enumerated in the summary). If conflict-handling visibility becomes important, layer it on as a follow-up step that compares `rebased-count` vs open-PR count, or re-introduce the custom loop. CLAUDE.md updated to reflect the new (simpler) behavior. Token remains RELEASE_PAT for the same scope reasons — GITHUB_TOKEN can't push to fork PR head branches and can't force-push commits that touch .github/workflows/ without the `workflow` scope. --- .claude/CLAUDE.md | 2 +- .github/workflows/auto-rebase-prs.yaml | 226 ++++++------------------- 2 files changed, 57 insertions(+), 171 deletions(-) diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index 1de437a36b..e6b4da4993 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -468,7 +468,7 @@ Conventional commits drive semantic-release. `feat:` triggers a minor bump, `fix - **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. PRs from forks need "Allow edits by maintainers" enabled; the workflow summary lists any that were skipped for missing access. Conflicting PRs get a `needs-rebase` label and a comment. +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):** diff --git a/.github/workflows/auto-rebase-prs.yaml b/.github/workflows/auto-rebase-prs.yaml index 85bfefc6b0..4a33964cc0 100644 --- a/.github/workflows/auto-rebase-prs.yaml +++ b/.github/workflows/auto-rebase-prs.yaml @@ -1,30 +1,23 @@ name: "Auto-rebase open PRs" -# Rebase open PRs against dev whenever dev moves. Triggered by: -# - Pushes to dev (including the rebase-forward push from +# Rebase open PRs targeting `dev` whenever `dev` moves. Wraps +# peter-evans/rebase@v3 instead of hand-rolling the rebase loop. Triggered by: +# - Push to `dev` (including the rebase-forward push from # release-semantic.yaml after a hotfix release). -# - Manual dispatch (recovery after a missed run). -# -# Behavior per PR targeting dev: -# - Skip drafts and PRs labeled `no-auto-rebase`. -# - `git rebase origin/dev` the PR head branch. -# - On clean rebase: force-with-lease push, GitHub recomputes PR diff, -# PR checks re-run on the new head SHA. -# - On conflict: abort, leave a comment, apply `needs-rebase` label. +# - Manual dispatch (recovery after a missed run, or to target a single PR). # # Permissions / forks: pushing to a PR head branch on a fork requires a # user PAT belonging to a maintainer of this repo AND the PR author to -# have left "Allow edits by maintainers" checked. PRs that don't meet -# both conditions are skipped and logged in the workflow summary so a -# maintainer can ping the author. PRs whose head branch lives in this -# repo (collaborators) work unconditionally. +# have left "Allow edits by maintainers" checked. peter-evans/rebase +# silently skips PRs that don't meet both conditions; failed-conflict +# PRs are skipped too (no force-push attempted). The `rebased-count` +# output is surfaced in the job summary. # -# Token: RELEASE_PAT is reused here (same scopes the release workflows -# need). Required PAT scopes (classic): `repo` + `workflow`. The -# `workflow` scope is needed because a rebased PR may include -# `.github/workflows/` changes; force-pushing those without it would -# fail with "refusing to allow a Personal Access Token to create or -# update workflow". +# Token: RELEASE_PAT is reused (same scopes the release workflows need). +# Required PAT scopes (classic): `repo` + `workflow`. The `workflow` +# scope matters because a rebased PR may include `.github/workflows/` +# changes; force-pushing those without it would fail with "refusing to +# allow a Personal Access Token to create or update workflow". on: push: @@ -32,7 +25,7 @@ on: workflow_dispatch: inputs: pr_number: - description: "Optional: rebase just this PR number. Leave empty to rebase all." + 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: "" @@ -50,169 +43,62 @@ jobs: rebase: runs-on: ubuntu-latest steps: - - name: Checkout (for git CLI) + - name: Checkout uses: actions/checkout@v6 with: fetch-depth: 0 - persist-credentials: false token: ${{ secrets.RELEASE_PAT }} - - name: Configure git identity - run: | - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - - - name: Rebase open PRs against dev + - name: Resolve single-PR head spec (if pr_number given) + id: resolve_head + if: inputs.pr_number != '' env: GH_TOKEN: ${{ secrets.RELEASE_PAT }} - ONLY_PR: ${{ inputs.pr_number }} + PR_NUMBER: ${{ inputs.pr_number }} run: | set -euo pipefail + HEAD_REF=$(gh pr view "${PR_NUMBER}" --repo '${{ github.repository }}' \ + --json headRepositoryOwner,headRefName \ + --jq '"\(.headRepositoryOwner.login):\(.headRefName)"') + echo "head=${HEAD_REF}" >> "$GITHUB_OUTPUT" - # Best-effort: ensure the needs-rebase label exists. - gh label create needs-rebase \ - --repo '${{ github.repository }}' \ - --color d93f0b \ - --description "Auto-rebase failed due to merge conflicts." 2>/dev/null || true - - git fetch origin dev:refs/remotes/origin/dev - - if [[ -n "${ONLY_PR}" ]]; then - PR_JSON=$(gh pr view "${ONLY_PR}" --repo '${{ github.repository }}' \ - --json number,isDraft,headRefName,headRepository,headRepositoryOwner,maintainerCanModify,labels,state) - PRS=$(echo "${PR_JSON}" | jq -c '[.]') - else - PRS=$(gh pr list --repo '${{ github.repository }}' --base dev --state open \ - --json number,isDraft,headRefName,headRepository,headRepositoryOwner,maintainerCanModify,labels) - fi - - : > /tmp/summary.md - REBASED=0; SKIPPED=0; CONFLICTS=0; UNPUSHABLE=0 - declare -a UNPUSHABLE_LIST - - for row in $(echo "${PRS}" | jq -r '.[] | @base64'); do - _jq() { echo "${row}" | base64 -d | jq -r "$1"; } - NUM=$(_jq '.number') - DRAFT=$(_jq '.isDraft') - HEAD_REF=$(_jq '.headRefName') - HEAD_REPO=$(_jq '.headRepository.name // empty') - HEAD_OWNER=$(_jq '.headRepositoryOwner.login // empty') - MAINTAINER_CAN_MODIFY=$(_jq '.maintainerCanModify') - LABELS=$(_jq '[.labels[].name] | join(",")') - - if [[ "${DRAFT}" == "true" ]]; then - echo "- #${NUM}: skipped (draft)" >> /tmp/summary.md - SKIPPED=$((SKIPPED+1)); continue - fi - if [[ ",${LABELS}," == *",no-auto-rebase,"* ]]; then - echo "- #${NUM}: skipped (no-auto-rebase label)" >> /tmp/summary.md - SKIPPED=$((SKIPPED+1)); continue - fi - - SAME_REPO_OWNER='${{ github.repository_owner }}' - IS_FORK="true" - if [[ "${HEAD_OWNER}" == "${SAME_REPO_OWNER}" ]]; then - IS_FORK="false" - fi - if [[ "${IS_FORK}" == "true" && "${MAINTAINER_CAN_MODIFY}" != "true" ]]; then - echo "- #${NUM}: skipped (fork without maintainer-edit access — ask author to enable)" >> /tmp/summary.md - UNPUSHABLE=$((UNPUSHABLE+1)) - UNPUSHABLE_LIST+=("#${NUM}") - continue - fi - - # Build the remote URL for the PR head repo. For - # same-repo PRs HEAD_OWNER == repo owner. For forks - # we use the fork's owner/name. The PAT must have - # write access to both. - PR_REMOTE="https://x-access-token:${GH_TOKEN}@github.com/${HEAD_OWNER}/${HEAD_REPO}.git" - - # Fetch the PR head into a detached local ref. Use a - # unique local ref to avoid clobbering across loop - # iterations. - LOCAL_REF="refs/auto-rebase/pr-${NUM}" - if ! git fetch "${PR_REMOTE}" "+refs/heads/${HEAD_REF}:${LOCAL_REF}" 2>/tmp/fetch.err; then - echo "- #${NUM}: skipped (could not fetch head — see fetch error)" >> /tmp/summary.md - cat /tmp/fetch.err >&2 - SKIPPED=$((SKIPPED+1)); continue - fi - - HEAD_SHA=$(git rev-parse "${LOCAL_REF}") - - # Fast-path: PR head already contains origin/dev. - if git merge-base --is-ancestor origin/dev "${HEAD_SHA}"; then - echo "- #${NUM}: already up-to-date with dev" >> /tmp/summary.md - SKIPPED=$((SKIPPED+1)); continue - fi - - # Attempt the rebase in a worktree to keep the main - # checkout clean. - WORKTREE="/tmp/wt-pr-${NUM}" - git worktree add --detach "${WORKTREE}" "${HEAD_SHA}" >/dev/null - REBASE_OK=true - (cd "${WORKTREE}" && git rebase origin/dev) || REBASE_OK=false - - if [[ "${REBASE_OK}" != "true" ]]; then - (cd "${WORKTREE}" && git rebase --abort) || true - git worktree remove --force "${WORKTREE}" || true - echo "- #${NUM}: ⚠️ conflict during rebase" >> /tmp/summary.md - CONFLICTS=$((CONFLICTS+1)) - gh pr edit "${NUM}" --repo '${{ github.repository }}' --add-label needs-rebase || true - cat > /tmp/conflict-comment.md <<'COMMENT_EOF' - 🤖 Auto-rebase failed: `dev` moved and this PR has conflicts. Please rebase manually: - - ```bash - git fetch origin - git rebase origin/dev - # resolve conflicts, git rebase --continue - git push --force-with-lease - ``` - - I'll remove the `needs-rebase` label automatically when you push the rebased branch. - COMMENT_EOF - # Strip the YAML-required leading indent from the heredoc so - # the comment renders correctly on the PR. - sed -i 's/^ //' /tmp/conflict-comment.md - gh pr comment "${NUM}" --repo '${{ github.repository }}' \ - --body-file /tmp/conflict-comment.md || true - continue - fi - - NEW_SHA=$(cd "${WORKTREE}" && git rev-parse HEAD) - if [[ "${NEW_SHA}" == "${HEAD_SHA}" ]]; then - git worktree remove --force "${WORKTREE}" || true - echo "- #${NUM}: no-op rebase (already linear on dev)" >> /tmp/summary.md - SKIPPED=$((SKIPPED+1)); continue - fi - - if (cd "${WORKTREE}" && git push --force-with-lease="${HEAD_REF}:${HEAD_SHA}" \ - "${PR_REMOTE}" "HEAD:refs/heads/${HEAD_REF}"); then - REBASED=$((REBASED+1)) - echo "- #${NUM}: rebased ${HEAD_SHA:0:9} → ${NEW_SHA:0:9}" >> /tmp/summary.md - # Remove needs-rebase label if present (post-recovery). - gh pr edit "${NUM}" --repo '${{ github.repository }}' --remove-label needs-rebase 2>/dev/null || true - else - echo "- #${NUM}: ⚠️ push rejected (lease check failed — head moved during rebase)" >> /tmp/summary.md - SKIPPED=$((SKIPPED+1)) - fi - git worktree remove --force "${WORKTREE}" || true - done + - name: Rebase open PRs against dev + id: rebase + uses: peter-evans/rebase@v3 + with: + # RELEASE_PAT (not GITHUB_TOKEN): GITHUB_TOKEN can't + # push to PR head branches on contributor forks, and + # would also fail to force-push workflow file changes + # without the `workflow` scope. See workflow header. + 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 "| | |" - echo "|-|-|" - echo "| Rebased and pushed | ${REBASED} |" - echo "| Skipped (up-to-date / draft / labeled / no-op) | ${SKIPPED} |" - echo "| Conflicts (needs-rebase applied) | ${CONFLICTS} |" - echo "| Skipped — fork without maintainer-edit access | ${UNPUSHABLE} |" - if (( UNPUSHABLE > 0 )); then - echo "" - echo "### Forks needing maintainer-edit" - for p in "${UNPUSHABLE_LIST[@]}"; do echo "- ${p}"; done + 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 "### Per-PR results" - cat /tmp/summary.md + 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" From edcd19ae915f35f1d2c4cb1721678758c32a23fa Mon Sep 17 00:00:00 2001 From: Alan Tse Date: Mon, 18 May 2026 21:28:50 -0700 Subject: [PATCH 3/6] ci: address CodeRabbit findings on rebase-reconcile MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two valid findings from CodeRabbit on PR #21: 1. auto-rebase-prs.yaml: validate manual `pr_number` is OPEN and targets `dev` before rebasing. Without the guard, a wrong number could rebase and force-push a PR that targets a hotfix-staging branch (or any other base), silently corrupting it. The validation now exits cleanly with a `::warning::` if state != OPEN or baseRefName != dev, and an `if:` guard on the rebase step prevents the empty-head value from falling through to the all-PRs default path when validation rejected the input. 2. release-semantic.yaml: rebase-reconcile push lease failure was returning `exit 0`, so the workflow appeared green while dev remained unreconciled. That weakens the lineage invariant and makes follow-up easy to miss (the next RC on dev would compute against the wrong floor — same class of bug that caused the v1.5.2 hotfix to be invisible). Changed to `exit 1` with an `::error::` so the run goes red and forces a human to read the remediation block. Applied the same `exit 1` treatment to the dev-source FF-reconcile path on line 181 (not CodeRabbit-flagged because it's pre-existing code, but the same logical issue and the same invariant break). --- .github/workflows/auto-rebase-prs.yaml | 27 ++++++++++++++++++++++--- .github/workflows/release-semantic.yaml | 21 ++++++++++++++----- 2 files changed, 40 insertions(+), 8 deletions(-) diff --git a/.github/workflows/auto-rebase-prs.yaml b/.github/workflows/auto-rebase-prs.yaml index 4a33964cc0..cb16cbbabd 100644 --- a/.github/workflows/auto-rebase-prs.yaml +++ b/.github/workflows/auto-rebase-prs.yaml @@ -57,13 +57,34 @@ jobs: PR_NUMBER: ${{ inputs.pr_number }} run: | set -euo pipefail - HEAD_REF=$(gh pr view "${PR_NUMBER}" --repo '${{ github.repository }}' \ - --json headRepositoryOwner,headRefName \ - --jq '"\(.headRepositoryOwner.login):\(.headRefName)"') + # Validate the PR is open AND targets dev before + # rebasing — manual mode could otherwise rewrite a + # PR that targets a different base (e.g. a hotfix + # staging branch) and silently corrupt it. + 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 + # Skip if pr_number was provided but validation rejected it + # (state != OPEN or base != dev). Without this guard an + # empty `head` would fall through to the all-PRs default, + # which is the opposite of what a single-PR caller wanted. + if: inputs.pr_number == '' || steps.resolve_head.outputs.head != '' uses: peter-evans/rebase@v3 with: # RELEASE_PAT (not GITHUB_TOKEN): GITHUB_TOKEN can't diff --git a/.github/workflows/release-semantic.yaml b/.github/workflows/release-semantic.yaml index 016c22b35c..3a0922e7f7 100644 --- a/.github/workflows/release-semantic.yaml +++ b/.github/workflows/release-semantic.yaml @@ -168,17 +168,21 @@ 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 loudly: the release tag is already on main but + # dev is unreconciled, so the lineage invariant is + # broken. A green workflow would let it go unnoticed + # and the next RC would miss the released changes. + 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" @@ -243,8 +247,15 @@ jobs: echo "git rebase origin/main && git push --force-with-lease origin dev" echo '```' } >> "$GITHUB_STEP_SUMMARY" - echo "::warning::Dev moved during release — manual reconcile required." - exit 0 + # Fail the job (not exit 0): the release commit and + # tag have already been pushed by semantic-release at + # this point, but dev is now unreconciled — the + # lineage invariant is broken. A green workflow would + # let that go unnoticed and the next RC on dev would + # repeat the v1.5.2-style miss. A red workflow forces + # a human to read the remediation block above. + echo "::error::Dev moved during release — dev is unreconciled. See job summary for manual remediation." + exit 1 fi { From 324a6c8e70e17efeb0c99bdd7b93230bda148aec Mon Sep 17 00:00:00 2001 From: Alan Tse Date: Mon, 18 May 2026 23:12:06 -0700 Subject: [PATCH 4/6] ci: trim paraphrase comments in reconcile workflows Apply the new concise-comments directive to the workflows added in this PR. Removes ~40 lines of block comments that paraphrased the adjacent code or repeated input/step descriptions. Behavior unchanged. --- .github/workflows/auto-rebase-prs.yaml | 40 ++++++------------- .github/workflows/release-semantic.yaml | 53 +++++++------------------ 2 files changed, 26 insertions(+), 67 deletions(-) diff --git a/.github/workflows/auto-rebase-prs.yaml b/.github/workflows/auto-rebase-prs.yaml index cb16cbbabd..a455bfe8a2 100644 --- a/.github/workflows/auto-rebase-prs.yaml +++ b/.github/workflows/auto-rebase-prs.yaml @@ -1,23 +1,15 @@ name: "Auto-rebase open PRs" -# Rebase open PRs targeting `dev` whenever `dev` moves. Wraps -# peter-evans/rebase@v3 instead of hand-rolling the rebase loop. Triggered by: -# - Push to `dev` (including the rebase-forward push from -# release-semantic.yaml after a hotfix release). -# - Manual dispatch (recovery after a missed run, or to target a single PR). +# Rebase open PRs against `dev` whenever it moves. Thin wrapper over +# peter-evans/rebase@v3. # -# Permissions / forks: pushing to a PR head branch on a fork requires a -# user PAT belonging to a maintainer of this repo AND the PR author to -# have left "Allow edits by maintainers" checked. peter-evans/rebase -# silently skips PRs that don't meet both conditions; failed-conflict -# PRs are skipped too (no force-push attempted). The `rebased-count` -# output is surfaced in the job summary. +# 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 is reused (same scopes the release workflows need). -# Required PAT scopes (classic): `repo` + `workflow`. The `workflow` -# scope matters because a rebased PR may include `.github/workflows/` -# changes; force-pushing those without it would fail with "refusing to -# allow a Personal Access Token to create or update workflow". +# Token: RELEASE_PAT (classic PAT, `repo` + `workflow`). `workflow` is +# required because rebased PRs may include `.github/workflows/` diffs. on: push: @@ -57,10 +49,8 @@ jobs: PR_NUMBER: ${{ inputs.pr_number }} run: | set -euo pipefail - # Validate the PR is open AND targets dev before - # rebasing — manual mode could otherwise rewrite a - # PR that targets a different base (e.g. a hotfix - # staging branch) and silently corrupt it. + # 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') @@ -80,17 +70,11 @@ jobs: - name: Rebase open PRs against dev id: rebase - # Skip if pr_number was provided but validation rejected it - # (state != OPEN or base != dev). Without this guard an - # empty `head` would fall through to the all-PRs default, - # which is the opposite of what a single-PR caller wanted. + # Skip if pr_number was given but validation rejected it — + # empty `head` would otherwise default to "all open PRs". if: inputs.pr_number == '' || steps.resolve_head.outputs.head != '' uses: peter-evans/rebase@v3 with: - # RELEASE_PAT (not GITHUB_TOKEN): GITHUB_TOKEN can't - # push to PR head branches on contributor forks, and - # would also fail to force-push workflow file changes - # without the `workflow` scope. See workflow header. token: ${{ secrets.RELEASE_PAT }} base: dev head: ${{ steps.resolve_head.outputs.head }} diff --git a/.github/workflows/release-semantic.yaml b/.github/workflows/release-semantic.yaml index 3a0922e7f7..2f0b882267 100644 --- a/.github/workflows/release-semantic.yaml +++ b/.github/workflows/release-semantic.yaml @@ -1,27 +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 mode (hotfix-staging → main, current-line patch): -# Dispatch on main with `ff_target` set to a hotfix-staging SHA. The -# workflow fast-forwards main, cuts the patch release, then -# rebase-reconciles dev onto the new main so the release commit (and -# tag) enter dev's ancestry. `git rebase` drops commits whose patch-id -# already matches an upstream cherry-pick, so dev's history stays -# linear and free of duplicates. This requires a force-with-lease push -# to dev (the only force-push this workflow performs); see the -# `auto-rebase-prs.yaml` workflow which auto-rebases open PRs targeting -# dev after this happens. +# 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: @@ -177,10 +163,8 @@ jobs: echo "git fetch && git checkout dev && git merge --ff-only origin/main && git push" echo '```' } >> "$GITHUB_STEP_SUMMARY" - # Fail loudly: the release tag is already on main but - # dev is unreconciled, so the lineage invariant is - # broken. A green workflow would let it go unnoticed - # and the next RC would miss the released changes. + # 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 @@ -200,13 +184,9 @@ jobs: NEW_MAIN=$(git rev-parse origin/main) CURRENT_DEV=$(git rev-parse origin/dev) - # A plain FF-reconcile won't work here: dev is not an - # ancestor of new main (they share an older common - # ancestor; main now has the hotfix cherry-picks + release - # chore commit, dev has its own forward progress). Rebase - # dev onto new main to preserve linear history. `git - # rebase` automatically drops commits whose patch-id - # matches an upstream commit (the cherry-picks). + # 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 @@ -247,13 +227,8 @@ jobs: echo "git rebase origin/main && git push --force-with-lease origin dev" echo '```' } >> "$GITHUB_STEP_SUMMARY" - # Fail the job (not exit 0): the release commit and - # tag have already been pushed by semantic-release at - # this point, but dev is now unreconciled — the - # lineage invariant is broken. A green workflow would - # let that go unnoticed and the next RC on dev would - # repeat the v1.5.2-style miss. A red workflow forces - # a human to read the remediation block above. + # Fail loud: tag is on main but dev isn't reconciled, + # so the next RC would repeat the v1.5.2-style miss. echo "::error::Dev moved during release — dev is unreconciled. See job summary for manual remediation." exit 1 fi From e4cd6166450a72eefe137e9a4644416326abcb24 Mon Sep 17 00:00:00 2001 From: Alan Tse Date: Mon, 18 May 2026 23:25:26 -0700 Subject: [PATCH 5/6] ci: drop past-tense references from reconcile comments Apply the present-tense-comments directive. Reframe two comments that named past state ("pr_number was given but validation rejected it", "v1.5.2-style miss") to describe the present invariant the code protects. Behavior unchanged. --- .github/workflows/auto-rebase-prs.yaml | 4 ++-- .github/workflows/release-semantic.yaml | 5 +++-- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/.github/workflows/auto-rebase-prs.yaml b/.github/workflows/auto-rebase-prs.yaml index a455bfe8a2..e6dc3e9920 100644 --- a/.github/workflows/auto-rebase-prs.yaml +++ b/.github/workflows/auto-rebase-prs.yaml @@ -70,8 +70,8 @@ jobs: - name: Rebase open PRs against dev id: rebase - # Skip if pr_number was given but validation rejected it — - # empty `head` would otherwise default to "all open PRs". + # 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: diff --git a/.github/workflows/release-semantic.yaml b/.github/workflows/release-semantic.yaml index 2f0b882267..57f1551780 100644 --- a/.github/workflows/release-semantic.yaml +++ b/.github/workflows/release-semantic.yaml @@ -227,8 +227,9 @@ jobs: echo "git rebase origin/main && git push --force-with-lease origin dev" echo '```' } >> "$GITHUB_STEP_SUMMARY" - # Fail loud: tag is on main but dev isn't reconciled, - # so the next RC would repeat the v1.5.2-style miss. + # 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 From c5c68ea6fefe2be674a094686e8778c27f0e9240 Mon Sep 17 00:00:00 2001 From: Alan Tse Date: Wed, 20 May 2026 00:36:25 -0700 Subject: [PATCH 6/6] ci: nudge CodeQL Empty commit to trigger CodeQL on this PR. The default-setup CodeQL workflow only fires on synchronize events, not reopened, so a push is required to gate the PR on the new code scan. Co-Authored-By: Claude Opus 4.7