From 6d00ba13c5910fab6f1bcfaacc19bc5f2003ad6b Mon Sep 17 00:00:00 2001 From: Preetam Dwivedi Date: Wed, 7 Oct 2026 12:19:23 -0700 Subject: [PATCH] ci: remove rebase-stack workflow in favor of GitHub native stacks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary ### Why? Stacked PRs now go through GitHub native stacks (gh stack). GitHub rebases and retargets the remaining stack members on its own when part of a stack merges. That makes the custom `rebase-stack.yml` workflow redundant. It already skipped native-stack members, so the only stacks it still rebased were hand-built `arh` chains. Removing it also drops the repo's single `pull_request_target` workflow and the `STACK_REBASE_TOKEN` PAT it force-pushed with. ### What? - Delete `.github/workflows/rebase-stack.yml`. - Remove its zizmor exceptions in `.github/zizmor.yml`: the whole `dangerous-triggers` ignore, and its `artipacked` entry. `tag-go-pseudoversion.yml` keeps its `artipacked` exception. ## Test Plan ✅ `make lint` After merge, the `STACK_REBASE_TOKEN` repository secret can be deleted. --- .github/workflows/rebase-stack.yml | 452 ----------------------------- .github/zizmor.yml | 21 +- 2 files changed, 5 insertions(+), 468 deletions(-) delete mode 100644 .github/workflows/rebase-stack.yml diff --git a/.github/workflows/rebase-stack.yml b/.github/workflows/rebase-stack.yml deleted file mode 100644 index 87a3e59f2..000000000 --- a/.github/workflows/rebase-stack.yml +++ /dev/null @@ -1,452 +0,0 @@ -# Rebase Stacked PRs -# -# Problem: -# When using stacked PRs (main -> PR1 -> PR2 -> PR3), merging PR1 via -# squash or rebase causes GitHub to retarget PR2's base to main. However, -# PR2's branch still contains PR1's original commits, so its diff shows -# both PR1 and PR2 changes — a broken diff that confuses reviewers. -# -# Solution: -# This workflow triggers when any PR is merged and automatically: -# 1. Checks each candidate PR for GitHub *native* stack membership (the -# `stack` object on the pull request resource — see NATIVE STACKS below) -# and leaves those to GitHub, which rebases and retargets the members of -# its own stacks. Everything else — standalone PRs, and hand-rolled chains -# where each PR simply targets the one below it, as `arh` produces — is -# rebased here. -# 2. Finds all open PRs whose base branch is the merged PR's head branch -# (i.e., the next PR in the stack). -# 3. Rebases each child PR onto the merged PR's base (e.g., main), using -# "git rebase --onto" to replay only the child's own commits. A child that -# is itself a native-stack member is skipped, for the same reason as (1). -# 4. Walks the full chain recursively — if PR2 is rebased, PR3 (based on -# PR2) is also rebased onto the new PR2, and so on to any depth. -# 5. Validates the diff is identical before and after rebase — if the -# rebase silently altered code, it refuses to force-push. -# 6. If a rebase hits conflicts, it leaves a comment with manual fix -# instructions and stops processing that chain. -# 7. Sweeps merged head branches and deletes any that no open PR still -# references (as base or head). This deletes the just-merged branch once -# its children are retargeted, keeps it when an immediate child rebase -# failed (that child still bases on it), and — because the sweep runs on -# every merge — also reaps branches stranded by an earlier conflicted run -# after their children were manually rebased and retargeted. -# -# NATIVE STACKS (github/gh-stack): -# A pull request that is part of a GitHub native stack carries a non-null -# `stack` object (id, number, size, position, base). GitHub owns those PRs: -# on a partial merge it rebases and retargets the remaining MEMBERS of the -# stack itself. Rebasing them here would race that machinery and force-push -# over its work, so they are skipped. -# -# The check is applied per CHILD PR, not to the merged PR. GitHub only ever -# touches a stack's own members; a PR that targets a member's head branch -# without joining the stack is invisible to it. Skipping the whole chain just -# because the merged PR happened to be a stack member would strand such a PR -# with exactly the broken diff this workflow exists to prevent — so the chain -# always runs and each child is judged on its own membership. Membership of -# the merged PR is logged for context and gates nothing. -# -# Membership survives merge (a merged member still reports its stack), which -# is what makes the lookup meaningful at this point in the lifecycle. It hits -# the REST API at run time rather than reading the webhook payload, so a PR -# added to a stack after the merge event was queued is still recognised. -# -# Presence is tested on the `stack` object itself, not on a sub-field such as -# `stack.number`: a stack object arriving without the field it was probed for -# would read as "standalone" and get force-pushed. An unreadable response is -# deliberately treated as standalone — the Stacks API 404s when the feature -# is not enabled for the repo, and that is precisely when the rebase is -# wanted — but that fallback must be reached only by a genuinely absent -# stack, never by an unexpected shape. -# -# Why "rebase --onto" instead of "--fork-point": -# GitHub Actions runs on a fresh clone with no reflog, so --fork-point -# (which arh uses locally) cannot detect fork points. Instead, we use -# the merged PR's head SHA from the event payload as the explicit old -# base, achieving the same result. -# -# Example: main -> PR1(branch1, C1) -> PR2(branch2, C2) -> PR3(branch3, C3) -# PR1 merges into main: -# 1. rebase --onto origin/main branch2 (replays C2) -# 2. rebase --onto branch3 (replays C3) -# 3. Delete branch1 -# Result: main -> PR2(branch2, C2') -> PR3(branch3, C3') -# -# REPO SETTING REQUIREMENT — "Automatically delete head branches" MUST be OFF: -# This workflow is the sole owner of head-branch deletion. If GitHub -# auto-deletes the merged head branch, it ALSO auto-retargets every child -# PR's base from the merged head branch to the merged base (e.g. main) -# BEFORE this workflow runs. The `gh pr list --base ` lookup -# below would then find no children, and the stack would be silently left -# un-rebased (the job still goes green). With the setting OFF, GitHub leaves -# the branch and the child bases untouched, so this job can find the -# children, rebase them, retarget their bases, and finally delete the merged -# branch itself — for both stacked and non-stacked PRs. -# -# This holds for native-stack merges too. The sweep runs on EVERY merge, -# including those where every child turned out to be GitHub's to rebase, so -# native-stack head branches are reaped here as well — each one on the first -# merge after GitHub has retargeted its successors off it. - -name: Rebase Stacked PRs - -# SECURITY: this workflow is triggered by `pull_request_target`, NOT -# `pull_request`. The distinction is critical because this job holds a -# privileged, repo-write PAT (STACK_REBASE_TOKEN). -# -# - `pull_request` runs the workflow file from the PR's HEAD ref. -# A contributor could edit THIS file in their own PR -# (e.g. drop the `merged` guard), close the PR, and -# run arbitrary code with the PAT. Same-repo branch -# PRs (the stacked-PR model) DO receive secrets, so -# this is exploitable — a secret-exfiltration hole. -# - `pull_request_target` always runs the workflow file from the DEFAULT -# branch (main). PR edits to this file are ignored, -# so the privileged logic is fixed at main's version. -# -# `pull_request_target` is safe HERE specifically because the job never builds -# or executes PR code: it only replays commits with `git rebase` (which runs -# nothing from the tree) and validates the resulting diff is byte-identical -# before force-pushing. It does not run `make`, tests, or any PR script. -on: - pull_request_target: - types: - - closed - -permissions: - checks: write - contents: write - pull-requests: write - -jobs: - rebase-stack: - name: Rebase Stack - # Runs on every merge. Native-stack membership is checked per child PR - # inside the job, not here: a single merge can have both stack members - # (GitHub's to rebase) and children outside the stack (ours), and the - # branch sweep has to run either way. - if: github.event.pull_request.merged == true - runs-on: ubuntu-latest - # Scope the privileged PAT to a protected Environment restricted to `main` - # so the secret cannot be used from any other ref/context. - environment: stack-rebase - steps: - - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 - with: - # Fetch full history so rebase --onto works correctly. - fetch-depth: 0 - # Use a personal access token (stored as STACK_REBASE_TOKEN) so the - # force-push below is attributed to a user and triggers downstream - # workflows (CI). Pushes authenticated with the default GITHUB_TOKEN - # are intentionally ignored by GitHub's workflow trigger to prevent - # recursive runs, which would leave rebased PRs without a CI signal. - token: ${{ secrets.STACK_REBASE_TOKEN }} - - - name: Rebase stacked PRs - env: - GH_TOKEN: ${{ secrets.STACK_REBASE_TOKEN }} - MERGED_PR_NUMBER: ${{ github.event.pull_request.number }} - MERGED_HEAD: ${{ github.event.pull_request.head.ref }} - MERGED_BASE: ${{ github.event.pull_request.base.ref }} - MERGED_HEAD_SHA: ${{ github.event.pull_request.head.sha }} - run: | - set -euo pipefail - - git config user.name "github-actions[bot]" - git config user.email "github-actions[bot]@users.noreply.github.com" - - # detect_stack sets STACK_NUMBER to the GitHub native stack number the - # given PR belongs to, or "" when it is standalone. GitHub rebases and - # retargets the members of its own stacks, so a non-empty STACK_NUMBER - # means "hands off — this PR is not ours to rebase". - # - # Membership is decided by `.stack` being non-null rather than by - # reading `.stack.number`, so a stack object that lacked the probed - # field could not read as standalone and get force-pushed; the number - # is only ever used for logging, hence the "?" fallback. - # - # An unreadable response is deliberately treated as standalone: the - # Stacks API returns 404 when the feature is not enabled for the repo, - # and that is precisely the case where the rebase below is wanted. - # The result is written to a global instead of stdout so the warning - # cannot end up captured as the value by the caller. - STACK_NUMBER="" - detect_stack() { - local pr_number="$1" - local out - STACK_NUMBER="" - - if ! out=$(gh api "repos/{owner}/{repo}/pulls/${pr_number}" \ - --jq 'if .stack == null then "" else "\(.stack.number // "?")" end' 2>&1); then - echo "::warning::Could not read stack membership for PR #${pr_number}; treating it as standalone. Details: ${out}" - return 0 - fi - - STACK_NUMBER="$out" - } - - # rebase_chain walks the stack depth-first, rebasing each child PR - # onto its new base and recursing into grandchildren. - # - # It uses "git rebase --onto" to replay only a branch's OWN commits: - # git rebase --onto - # This takes the commits between old_base_sha and branch tip, and - # replays them onto new_base — discarding the parent's commits that - # the child was carrying. - # - # Args: - # $1 - lookup_base: branch name to find child PRs (gh pr list --base) - # $2 - rebase_onto: SHA or ref to rebase onto - # $3 - old_base_sha: SHA of the old base tip; commits between this and - # the child branch tip are the child's own commits - # $4 - new_pr_base: branch name to set as the PR's new base in GitHub - rebase_chain() { - local lookup_base="$1" - local rebase_onto="$2" - local old_base_sha="$3" - local new_pr_base="$4" - - # Find open PRs whose base branch matches the lookup branch. - local prs - prs=$(gh pr list \ - --base "$lookup_base" \ - --state open \ - --json number,headRefName \ - --jq '.[] | "\(.number) \(.headRefName)"') - - if [ -z "$prs" ]; then - echo "No open child PRs based on '${lookup_base}' — nothing to rebase." - return 0 - fi - - while IFS=' ' read -r pr_number pr_branch; do - echo "" - echo "=== PR #${pr_number} (${pr_branch}) ===" - - # A child that belongs to a native stack is rebased and retargeted - # by GitHub itself. Skip it — and do NOT recurse into it, since - # GitHub walks the rest of that stack. Siblings are unaffected, so - # this is a `continue`, not a failure. - detect_stack "$pr_number" - if [ -n "$STACK_NUMBER" ]; then - echo " skipped: belongs to native stack #${STACK_NUMBER}; GitHub owns its rebase." - continue - fi - - echo " rebasing onto: ${rebase_onto}" - echo " old base SHA: ${old_base_sha}" - - git fetch origin "$pr_branch" - git checkout -B "$pr_branch" "origin/$pr_branch" - - # Save the pre-rebase tip. When we recurse into grandchildren, - # this becomes their old_base_sha (the boundary between this - # branch's commits and the grandchild's commits). - local old_child_tip - old_child_tip=$(git rev-parse HEAD) - - # Capture the patch (code changes only) before rebase. After - # rebase we compare this to the new patch — a correct rebase - # must produce an identical diff. If it doesn't, the rebase - # silently altered code and we refuse to force-push. - local diff_before - diff_before=$(git diff "$old_base_sha"..HEAD) - - # Replay only this branch's own commits onto the new base. - if ! git rebase --onto "$rebase_onto" "$old_base_sha" "$pr_branch" 2>&1; then - echo "::warning::Rebase failed for PR #${pr_number} (${pr_branch})." - git rebase --abort 2>/dev/null || true - - # Leave instructions for manual resolution. - local comment_body - comment_body=$(cat < ${new_child_tip}" - - # Safety check: verify the rebase preserved the exact same code - # changes. The diff of the branch's own commits against its base - # must be identical before and after rebase. If not, the rebase - # altered code (e.g., bad conflict auto-resolution) and we refuse - # to force-push. - local diff_after - diff_after=$(git diff "$rebase_onto"..HEAD) - - if [ "$diff_before" != "$diff_after" ]; then - echo "::error::Diff mismatch after rebase for PR #${pr_number} (${pr_branch})!" - echo " The rebase changed the code content. Refusing to force-push." - - gh pr comment "$pr_number" --body ":stop_sign: **Automatic stack rebase aborted — diff mismatch** - - The rebase of \`$pr_branch\` completed without conflicts, but the resulting code diff does not match the original. This means the rebase silently altered code content. The branch was **not** force-pushed. - - Please rebase manually and verify the changes are correct." - - return 1 - fi - - echo " diff validated: content unchanged after rebase" - - # Push the rebased branch. --force-with-lease fails if someone - # else pushed to this branch concurrently, preventing data loss. - if ! git push --force-with-lease origin "$pr_branch" 2>&1; then - echo "::warning::Force push failed for PR #${pr_number} (${pr_branch})." - - gh pr comment "$pr_number" --body ":warning: **Automatic stack rebase failed** - - The rebase succeeded but force-push failed for \`$pr_branch\`. This may be due to a concurrent push. Please rebase manually." - - return 1 - fi - - # Point the PR at the correct base branch in GitHub. - gh pr edit "$pr_number" --base "$new_pr_base" - echo " PR #${pr_number} base updated to '${new_pr_base}'." - - # Recurse into grandchildren: PRs whose base is this child's - # branch. They need to rebase onto the NEW child tip (post-rebase), - # using the OLD child tip as their fork point. Their GitHub base - # stays as pr_branch since that branch still exists. - rebase_chain "$pr_branch" "$new_child_tip" "$old_child_tip" "$pr_branch" || return 1 - - done <<< "$prs" - - return 0 - } - - # cleanup_orphaned_merged_branches enforces the branch-lifecycle - # invariant: a head branch is deleted once its PR is merged AND no - # open PR still references it (as a base — a child waiting to be - # rebased — or as a head). It runs on every invocation, so besides - # cleaning up the branch just merged, it also reaps branches left - # behind by an EARLIER conflicted run: when this job cannot auto- - # rebase a child it intentionally keeps the merged branch (the child - # still bases on it); that run never fires again, so nothing would - # otherwise delete the branch after the author manually rebases the - # child and retargets it off. This sweep closes that gap on the next - # merge. - cleanup_orphaned_merged_branches() { - echo "" - echo "Sweeping for merged head branches with no open dependents..." - - # One network round-trip for the set of branches that still exist. - local existing - existing=$(git ls-remote --heads origin | sed 's#.*refs/heads/##') - - local merged - merged=$(gh pr list \ - --state merged \ - --limit 100 \ - --json number,headRefName \ - --jq '.[] | "\(.number) \(.headRefName)"') - - if [ -z "$merged" ]; then - echo " No merged PRs to consider." - return 0 - fi - - # A branch may back several merged PRs over time; only consider it - # once. - local seen="" - while IFS=' ' read -r num branch; do - [ -n "$branch" ] || continue - case " $seen " in *" $branch "*) continue ;; esac - seen="$seen $branch" - - # Skip branches already gone, and never touch the merged base - # (e.g. main) even if it somehow shows up as a head ref. - grep -qxF "$branch" <<< "$existing" || continue - [ "$branch" = "$MERGED_BASE" ] && continue - - # Keep the branch while any OPEN PR still uses it as a base (a - # child waiting to be rebased) or as a head (the branch was reused - # for a new open PR). Deleting a base branch out from under an - # open child is exactly the breakage this workflow exists to - # prevent. - local base_deps head_uses - base_deps=$(gh pr list --base "$branch" --state open --json number --jq 'length') - head_uses=$(gh pr list --head "$branch" --state open --json number --jq 'length') - - if [ "$base_deps" = "0" ] && [ "$head_uses" = "0" ]; then - echo " Deleting orphaned merged branch '$branch' (PR #$num) — no open dependents." - git push origin --delete "$branch" 2>/dev/null || echo " (already gone)" - else - echo " Keeping '$branch' (open base deps=$base_deps, open head uses=$head_uses)." - fi - done <<< "$merged" - - return 0 - } - - echo "Merged PR: #${MERGED_PR_NUMBER} ${MERGED_HEAD} -> ${MERGED_BASE}" - echo "Merged head SHA: ${MERGED_HEAD_SHA}" - - git fetch origin "$MERGED_BASE" - - # Note whether the merged PR was itself a native-stack member. This is - # context for the log and nothing more: GitHub retargets that stack's - # own members, but any child that merely targets this branch without - # being in the stack is invisible to GitHub and still ours to fix, so - # the chain below runs regardless and each child is judged on its own - # membership. - detect_stack "$MERGED_PR_NUMBER" - if [ -n "$STACK_NUMBER" ]; then - echo "PR #${MERGED_PR_NUMBER} belongs to native stack #${STACK_NUMBER}; GitHub retargets its fellow members. Any child outside that stack is still rebased below." - fi - - # Kick off the recursive rebase. Immediate children of the merged PR - # get rebased onto MERGED_BASE, using MERGED_HEAD_SHA as the old - # fork point (the tip of the now-merged branch before it was deleted). - # "|| rebase_result=$?" prevents set -e from aborting — we always run - # the cleanup sweep regardless of rebase outcome. - rebase_result=0 - rebase_chain \ - "$MERGED_HEAD" \ - "origin/$MERGED_BASE" \ - "$MERGED_HEAD_SHA" \ - "$MERGED_BASE" \ - || rebase_result=$? - - echo "" - if [ "$rebase_result" -eq 0 ]; then - echo "=== Stack rebase complete ===" - else - echo "::warning::Rebase chain stopped due to conflicts; see the PR comment for manual steps." - echo "=== Stack rebase incomplete ===" - fi - - # Reap merged head branches that nothing depends on anymore. This - # deletes MERGED_HEAD on a clean run, KEEPS it when an immediate child - # rebase failed (that child still bases on it), and cleans up branches - # stranded by earlier conflicted runs once their children were fixed. - # For a native-stack merge it is likewise conservative: if GitHub has - # not retargeted the successors off MERGED_HEAD yet, the branch is kept - # and reaped by the sweep on a later merge. - cleanup_orphaned_merged_branches diff --git a/.github/zizmor.yml b/.github/zizmor.yml index af883a728..f4eaad2cc 100644 --- a/.github/zizmor.yml +++ b/.github/zizmor.yml @@ -4,24 +4,13 @@ # justification — not a blanket mute. New findings outside these are expected # to fail the `workflow-security` CI job. rules: - # rebase-stack.yml deliberately uses `pull_request_target` so the privileged - # workflow definition (which holds the STACK_REBASE_TOKEN PAT) is always taken - # from `main`, never from PR-controlled content. It never builds or runs PR - # code — only `git rebase` replay + byte-identical diff validation before - # force-push — so the usual pull_request_target code-execution risk does not - # apply. See the SECURITY comment block at the top of that workflow. - dangerous-triggers: - ignore: - - rebase-stack.yml - # These workflows MUST persist the checkout credentials because they push: - # - rebase-stack.yml force-pushes rebased branches using STACK_REBASE_TOKEN - # - tag-go-pseudoversion.yml pushes pseudo-version tags using the default token - # Neither uploads build artifacts, so persisted credentials cannot leak via - # the artipacked vector. CI jobs that DO run untrusted PR code set - # persist-credentials: false in ci.yml. + # tag-go-pseudoversion.yml MUST persist the checkout credentials because it + # pushes pseudo-version tags using the default token. It does not upload build + # artifacts, so persisted credentials cannot leak via the artipacked vector. + # CI jobs that DO run untrusted PR code set persist-credentials: false in + # ci.yml. artipacked: ignore: - - rebase-stack.yml - tag-go-pseudoversion.yml # dependabot.yml intentionally disables scheduled version-update PRs # (open-pull-requests-limit: 0) and relies only on security updates, which