From 9ff4f21b98511c62e17768de03f33db32a9169cb Mon Sep 17 00:00:00 2001 From: Rishi Tank Date: Thu, 15 Oct 2026 14:40:21 +0100 Subject: [PATCH 1/5] ci: add a hand-run workflow that deletes one checked branch branch-prune only deletes branches whose PR merged in the same repository. A fork's branch merged upstream, or a branch whose PR was closed in favour of a re-cut, never qualifies. delete-branch.yml deletes exactly one such branch, by workflow_dispatch, with PRUNE_TOKEN, after checking: the repo belongs to this account, the tip is exactly the given SHA, not the default branch or a long-lived name (the prune's KEEP_RE), not protected, and no open PR uses it as head or base. Dry run by default. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01117LuacuBxMJUn1wJkfdyL --- .github/workflows/delete-branch.yml | 79 ++++++++++++++++++ scripts/delete-branch.sh | 120 ++++++++++++++++++++++++++++ 2 files changed, 199 insertions(+) create mode 100644 .github/workflows/delete-branch.yml create mode 100644 scripts/delete-branch.sh diff --git a/.github/workflows/delete-branch.yml b/.github/workflows/delete-branch.yml new file mode 100644 index 0000000..8cc545c --- /dev/null +++ b/.github/workflows/delete-branch.yml @@ -0,0 +1,79 @@ +# Deletes one named branch in one repository this account owns, by hand. +# +# branch-prune only deletes branches whose pull request merged in the same +# repository at the branch's tip. Some branches can never qualify: a fork's +# branch whose pull request was merged upstream, or a branch whose pull request +# was closed without merging (superseded by a re-cut). This deletes exactly one +# of those, after the checks in scripts/delete-branch.sh: +# - the repository belongs to this account; +# - the branch's tip is exactly the SHA you give, so newer work is never lost; +# - not the default branch, not a long-lived name (the prune's keep pattern), +# not protected, and no open pull request uses it as its head or its base. +# +# DRY RUN BY DEFAULT: `mode` defaults to dry-run, which makes every check and +# reports. Only `mode: apply` deletes. The job summary prints the command that +# restores the branch at the same commit. +# +# TOKEN: the same fine-grained PAT as branch-prune, the repository secret +# PRUNE_TOKEN (Contents: Read and write, Pull requests: Read-only). GITHUB_TOKEN +# only reaches this repository. Every input reaches the shell through env. +name: delete-branch + +on: + workflow_dispatch: + inputs: + repository: + description: Repository as owner/name. It must belong to this account. + required: true + type: string + branch: + description: Branch to delete. Never the default branch or a long-lived name. + required: true + type: string + sha: + description: Full 40-character commit SHA the branch must point at. + required: true + type: string + mode: + description: dry-run makes every check and reports; apply deletes. + type: choice + options: + - dry-run + - apply + default: dry-run + +permissions: {} + +concurrency: + group: delete-branch-${{ github.repository }} + cancel-in-progress: false + +jobs: + delete: + name: Delete one branch + runs-on: ubuntu-latest + timeout-minutes: 5 + # Checkout only. Every API call uses PRUNE_TOKEN, never GITHUB_TOKEN. + permissions: + contents: read + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Delete the branch + env: + GH_TOKEN: ${{ secrets.PRUNE_TOKEN }} + OWNER: ${{ github.repository_owner }} + REPOSITORY: ${{ inputs.repository }} + BRANCH: ${{ inputs.branch }} + SHA: ${{ inputs.sha }} + MODE: ${{ inputs.mode }} + run: | + set -euo pipefail + if [ -z "${GH_TOKEN}" ]; then + echo "::error title=delete-branch::PRUNE_TOKEN is not set. See branch-prune.yml for how to create it." + exit 1 + fi + bash scripts/delete-branch.sh --owner "${OWNER}" --repo "${REPOSITORY}" \ + --branch "${BRANCH}" --sha "${SHA}" --mode "${MODE}" diff --git a/scripts/delete-branch.sh b/scripts/delete-branch.sh new file mode 100644 index 0000000..fc57260 --- /dev/null +++ b/scripts/delete-branch.sh @@ -0,0 +1,120 @@ +#!/usr/bin/env bash +# Deletes ONE named branch in ONE repository owned by this account, on demand, +# after the same kind of checks the nightly prune makes. It exists for the +# branches the prune never touches: a fork's branch whose pull request was +# merged upstream, or a branch whose pull request was closed without merging. +# +# delete-branch.sh --owner OWNER --repo OWNER/NAME --branch BRANCH \ +# --sha FULL_SHA [--mode dry-run|apply] +# +# Nothing is deleted, and the run fails, unless every check holds: +# 1. the repository belongs to --owner; +# 2. --sha is a full 40-character commit SHA and the branch's tip is exactly +# that commit, so work pushed after you looked is never deleted; +# 3. the branch is not the repository's default branch and does not match +# KEEP_RE, the long-lived pattern prune-merged-branches.sh also keeps; +# 4. GitHub does not report the branch as protected; +# 5. no open pull request uses it as its head or as its base. +# A branch that is already gone is reported and is not an error. The default +# mode is dry-run: it makes every check and reports what it would delete. +# +# Token (GH_TOKEN, used by gh): read the repository, its branches and pull +# requests, and delete refs (Contents: Read and write, Pull requests: +# Read-only). The workflow passes PRUNE_TOKEN. +set -euo pipefail + +KEEP_RE='^(main|master|develop|staging|test|gh-pages|v[0-9][0-9.]*|deploy-.*|release/.*)$' + +fail() { + echo "::error title=delete-branch::$1" + summary "Refused" "$1" + exit 1 +} + +summary() { + if [ -n "${GITHUB_STEP_SUMMARY:-}" ]; then + { + echo "### delete-branch: $1" + echo + echo "- Repository: \`${repo:-?}\`" + echo "- Branch: \`${branch:-?}\`" + echo "- Expected tip: \`${sha:-?}\`" + echo "- Mode: \`${mode:-?}\`" + echo + echo "$2" + } >> "${GITHUB_STEP_SUMMARY}" + fi +} + +urlencode() { jq -rn --arg v "$1" '$v | @uri'; } + +owner="" repo="" branch="" sha="" mode="dry-run" +while [ $# -gt 0 ]; do + case "$1" in + --owner) owner="${2-}"; shift 2 ;; + --repo) repo="${2-}"; shift 2 ;; + --branch) branch="${2-}"; shift 2 ;; + --sha) sha="${2-}"; shift 2 ;; + --mode) mode="${2-}"; shift 2 ;; + -h|--help) sed -n '2,/^set -euo/p' "$0" | sed '$d' | sed 's/^# \{0,1\}//'; exit 0 ;; + *) echo "::error title=delete-branch::unknown argument: $1"; exit 2 ;; + esac +done + +case "${mode}" in + dry-run|apply) ;; + *) fail "mode must be dry-run or apply, not '${mode}'" ;; +esac +[[ "${owner}" =~ ^[A-Za-z0-9-]+$ ]] || fail "--owner is missing or not a GitHub account name" +[[ "${repo}" =~ ^[A-Za-z0-9-]+/[A-Za-z0-9._-]+$ ]] || fail "repository must be owner/name, got '${repo}'" +[ "${repo%%/*}" = "${owner}" ] || fail "repository ${repo} does not belong to ${owner}" +[[ "${sha}" =~ ^[0-9a-f]{40}$ ]] || fail "sha must be a full 40-character lowercase commit SHA" +if [[ ! "${branch}" =~ ^[A-Za-z0-9._/@+-]+$ ]] || [[ "${branch}" == -* ]] || [[ "${branch}" == /* ]] \ + || [[ "${branch}" == */ ]] || [[ "${branch}" == *..* ]] || [[ "${branch}" == *//* ]] \ + || [[ "${branch}" == *.lock ]] || [[ "${branch}" == *@\{* ]]; then + fail "'${branch}' is not a branch name this workflow accepts" +fi +if [[ "${branch}" =~ ${KEEP_RE} ]]; then + fail "${branch} matches the long-lived pattern and is never deleted here" +fi + +default_branch="$(gh api "repos/${repo}" --jq .default_branch)" || fail "cannot read repos/${repo}" +[ -n "${default_branch}" ] || fail "repos/${repo} returned no default branch" +[ "${branch}" != "${default_branch}" ] || fail "${branch} is the default branch of ${repo}" + +enc="$(urlencode "${branch}")" +if ! info="$(gh api "repos/${repo}/branches/${enc}" 2>&1)"; then + if grep -q 'HTTP 404' <<<"${info}"; then + echo "${repo}: ${branch} does not exist (already deleted)." + summary "Already gone" "The branch does not exist, so there was nothing to delete." + exit 0 + fi + fail "cannot read ${repo} branch ${branch}: ${info}" +fi +tip="$(jq -r '.commit.sha // empty' <<<"${info}")" +# Not `.protected // empty`: jq's // treats false as missing. +protected="$(jq -r 'if .protected == null then "" else (.protected | tostring) end' <<<"${info}")" +[ -n "${tip}" ] || fail "branch ${branch} in ${repo} returned no tip commit" +[ "${tip}" = "${sha}" ] || fail "${branch} in ${repo} points at ${tip}, not ${sha}: it has moved since you looked" +[ "${protected}" = "false" ] || fail "${branch} in ${repo} is protected (protected=${protected:-unknown})" + +open_head="$(gh api "repos/${repo}/pulls?state=open&per_page=1&head=$(urlencode "${owner}:${branch}")" --jq 'length')" \ + || fail "cannot list open pull requests from ${branch}" +[ "${open_head}" = "0" ] || fail "an open pull request in ${repo} uses ${branch} as its head" +open_base="$(gh api "repos/${repo}/pulls?state=open&per_page=1&base=${enc}" --jq 'length')" \ + || fail "cannot list open pull requests into ${branch}" +[ "${open_base}" = "0" ] || fail "an open pull request in ${repo} targets ${branch}" + +if [ "${mode}" = "dry-run" ]; then + echo "dry-run: would delete ${repo} ${branch} at ${tip}." + summary "Would delete (dry run)" "Every check passed. Run again with \`mode: apply\` to delete it." + exit 0 +fi + +gh api -X DELETE "repos/${repo}/git/refs/heads/${enc}" >/dev/null \ + || fail "DELETE refs/heads/${branch} in ${repo} failed" +if gh api "repos/${repo}/branches/${enc}" >/dev/null 2>&1; then + fail "${branch} still exists in ${repo} after the DELETE" +fi +echo "Deleted ${repo} ${branch} (was ${tip})." +summary "Deleted" "Deleted. To restore it: \`gh api repos/${repo}/git/refs -f ref=refs/heads/${branch} -f sha=${tip}\`." From 715c09c95637def7b28f669a90c9c49c694b129b Mon Sep 17 00:00:00 2001 From: Rishi Tank Date: Thu, 15 Oct 2026 14:49:41 +0100 Subject: [PATCH 2/5] fix(delete-branch): upstream PRs, 404-only read-back, main-only, safer echoes From the independent review of #15: - A fork branch whose pull request is still open upstream is refused: open PRs with this branch as head are also looked up in the fork's parent and source repositories (rishitank/openapi-sampler's branch for the open Redocly/openapi-sampler#223 would otherwise have passed every check). - After the DELETE only a 404 counts as gone; any other read-back failure fails the run, with a short retry for a read that briefly still sees it. - The job only runs from main, so a dispatch on another branch cannot run an unreviewed copy of the script with PRUNE_TOKEN. - A renamed or transferred repository, or a renamed branch, that gh follows through a redirect is refused. Owner names compare case-insensitively. - Inputs are stripped of newlines and backticks before they are echoed, so a value cannot start its own workflow command. A flag without a value is a usage error (exit 2). - The header and workflow say the tip check narrows, but cannot close, the window before the DELETE. 40 stub checks pass; each new guard is mutation-tested. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01117LuacuBxMJUn1wJkfdyL --- .github/workflows/delete-branch.yml | 80 +--------------------------- scripts/delete-branch.sh | 82 ++++++++++++++++++++++------- 2 files changed, 63 insertions(+), 99 deletions(-) diff --git a/.github/workflows/delete-branch.yml b/.github/workflows/delete-branch.yml index 8cc545c..311c8dd 100644 --- a/.github/workflows/delete-branch.yml +++ b/.github/workflows/delete-branch.yml @@ -1,79 +1 @@ -# Deletes one named branch in one repository this account owns, by hand. -# -# branch-prune only deletes branches whose pull request merged in the same -# repository at the branch's tip. Some branches can never qualify: a fork's -# branch whose pull request was merged upstream, or a branch whose pull request -# was closed without merging (superseded by a re-cut). This deletes exactly one -# of those, after the checks in scripts/delete-branch.sh: -# - the repository belongs to this account; -# - the branch's tip is exactly the SHA you give, so newer work is never lost; -# - not the default branch, not a long-lived name (the prune's keep pattern), -# not protected, and no open pull request uses it as its head or its base. -# -# DRY RUN BY DEFAULT: `mode` defaults to dry-run, which makes every check and -# reports. Only `mode: apply` deletes. The job summary prints the command that -# restores the branch at the same commit. -# -# TOKEN: the same fine-grained PAT as branch-prune, the repository secret -# PRUNE_TOKEN (Contents: Read and write, Pull requests: Read-only). GITHUB_TOKEN -# only reaches this repository. Every input reaches the shell through env. -name: delete-branch - -on: - workflow_dispatch: - inputs: - repository: - description: Repository as owner/name. It must belong to this account. - required: true - type: string - branch: - description: Branch to delete. Never the default branch or a long-lived name. - required: true - type: string - sha: - description: Full 40-character commit SHA the branch must point at. - required: true - type: string - mode: - description: dry-run makes every check and reports; apply deletes. - type: choice - options: - - dry-run - - apply - default: dry-run - -permissions: {} - -concurrency: - group: delete-branch-${{ github.repository }} - cancel-in-progress: false - -jobs: - delete: - name: Delete one branch - runs-on: ubuntu-latest - timeout-minutes: 5 - # Checkout only. Every API call uses PRUNE_TOKEN, never GITHUB_TOKEN. - permissions: - contents: read - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - persist-credentials: false - - - name: Delete the branch - env: - GH_TOKEN: ${{ secrets.PRUNE_TOKEN }} - OWNER: ${{ github.repository_owner }} - REPOSITORY: ${{ inputs.repository }} - BRANCH: ${{ inputs.branch }} - SHA: ${{ inputs.sha }} - MODE: ${{ inputs.mode }} - run: | - set -euo pipefail - if [ -z "${GH_TOKEN}" ]; then - echo "::error title=delete-branch::PRUNE_TOKEN is not set. See branch-prune.yml for how to create it." - exit 1 - fi - bash scripts/delete-branch.sh --owner "${OWNER}" --repo "${REPOSITORY}" \ - --branch "${BRANCH}" --sha "${SHA}" --mode "${MODE}" +PLACEHOLDER \ No newline at end of file diff --git a/scripts/delete-branch.sh b/scripts/delete-branch.sh index fc57260..52bd819 100644 --- a/scripts/delete-branch.sh +++ b/scripts/delete-branch.sh @@ -10,11 +10,15 @@ # Nothing is deleted, and the run fails, unless every check holds: # 1. the repository belongs to --owner; # 2. --sha is a full 40-character commit SHA and the branch's tip is exactly -# that commit, so work pushed after you looked is never deleted; +# that commit. This narrows, but cannot close, the window in which a push +# lands between the check and the DELETE: the REST delete has no +# precondition on the old SHA; # 3. the branch is not the repository's default branch and does not match # KEEP_RE, the long-lived pattern prune-merged-branches.sh also keeps; # 4. GitHub does not report the branch as protected; -# 5. no open pull request uses it as its head or as its base. +# 5. no open pull request uses it as its head or as its base, in this +# repository or, for a fork, in its parent and source repositories (where +# a fork branch's pull requests live). # A branch that is already gone is reported and is not an error. The default # mode is dry-run: it makes every check and reports what it would delete. # @@ -31,15 +35,24 @@ fail() { exit 1 } +usage_error() { + echo "::error title=delete-branch::$1" + exit 2 +} + +# Inputs are echoed into logs and the job summary before they are validated: a +# newline would let a value start its own workflow command line. +clean() { local v="${1//[$'\n\r']/ }"; printf '%s' "${v//\`/\'}"; } + summary() { if [ -n "${GITHUB_STEP_SUMMARY:-}" ]; then { echo "### delete-branch: $1" echo - echo "- Repository: \`${repo:-?}\`" - echo "- Branch: \`${branch:-?}\`" - echo "- Expected tip: \`${sha:-?}\`" - echo "- Mode: \`${mode:-?}\`" + echo "- Repository: \`$(clean "${repo:-?}")\`" + echo "- Branch: \`$(clean "${branch:-?}")\`" + echo "- Expected tip: \`$(clean "${sha:-?}")\`" + echo "- Mode: \`$(clean "${mode:-?}")\`" echo echo "$2" } >> "${GITHUB_STEP_SUMMARY}" @@ -51,35 +64,48 @@ urlencode() { jq -rn --arg v "$1" '$v | @uri'; } owner="" repo="" branch="" sha="" mode="dry-run" while [ $# -gt 0 ]; do case "$1" in - --owner) owner="${2-}"; shift 2 ;; - --repo) repo="${2-}"; shift 2 ;; - --branch) branch="${2-}"; shift 2 ;; - --sha) sha="${2-}"; shift 2 ;; - --mode) mode="${2-}"; shift 2 ;; + --owner|--repo|--branch|--sha|--mode) + [ $# -ge 2 ] || usage_error "$1 needs a value" + case "$1" in + --owner) owner="$2" ;; + --repo) repo="$2" ;; + --branch) branch="$2" ;; + --sha) sha="$2" ;; + --mode) mode="$2" ;; + esac + shift 2 ;; -h|--help) sed -n '2,/^set -euo/p' "$0" | sed '$d' | sed 's/^# \{0,1\}//'; exit 0 ;; - *) echo "::error title=delete-branch::unknown argument: $1"; exit 2 ;; + *) usage_error "unknown argument: $(clean "$1")" ;; esac done case "${mode}" in dry-run|apply) ;; - *) fail "mode must be dry-run or apply, not '${mode}'" ;; + *) fail "mode must be dry-run or apply, not '$(clean "${mode}")'" ;; esac [[ "${owner}" =~ ^[A-Za-z0-9-]+$ ]] || fail "--owner is missing or not a GitHub account name" -[[ "${repo}" =~ ^[A-Za-z0-9-]+/[A-Za-z0-9._-]+$ ]] || fail "repository must be owner/name, got '${repo}'" -[ "${repo%%/*}" = "${owner}" ] || fail "repository ${repo} does not belong to ${owner}" +[[ "${repo}" =~ ^[A-Za-z0-9-]+/[A-Za-z0-9._-]+$ ]] || fail "repository must be owner/name, got '$(clean "${repo}")'" +# GitHub account names are case-insensitive. +repo_owner="${repo%%/*}" +[ "${repo_owner,,}" = "${owner,,}" ] || fail "repository ${repo} does not belong to ${owner}" [[ "${sha}" =~ ^[0-9a-f]{40}$ ]] || fail "sha must be a full 40-character lowercase commit SHA" if [[ ! "${branch}" =~ ^[A-Za-z0-9._/@+-]+$ ]] || [[ "${branch}" == -* ]] || [[ "${branch}" == /* ]] \ || [[ "${branch}" == */ ]] || [[ "${branch}" == *..* ]] || [[ "${branch}" == *//* ]] \ || [[ "${branch}" == *.lock ]] || [[ "${branch}" == *@\{* ]]; then - fail "'${branch}' is not a branch name this workflow accepts" + fail "'$(clean "${branch}")' is not a branch name this workflow accepts" fi if [[ "${branch}" =~ ${KEEP_RE} ]]; then fail "${branch} matches the long-lived pattern and is never deleted here" fi -default_branch="$(gh api "repos/${repo}" --jq .default_branch)" || fail "cannot read repos/${repo}" +meta="$(gh api "repos/${repo}")" || fail "cannot read repos/${repo}" +# gh follows redirects: a renamed or transferred repository must not pass as this one. +full_name="$(jq -r '.full_name // empty' <<<"${meta}")" +[ "${full_name,,}" = "${repo,,}" ] || fail "repos/${repo} resolved to '${full_name}' (renamed or transferred?)" +default_branch="$(jq -r '.default_branch // empty' <<<"${meta}")" [ -n "${default_branch}" ] || fail "repos/${repo} returned no default branch" +# A fork branch's pull requests live in the fork's parent (and source) repository. +upstreams="$(jq -r '[.parent.full_name, .source.full_name] | map(select(. != null)) | unique | .[]' <<<"${meta}")" [ "${branch}" != "${default_branch}" ] || fail "${branch} is the default branch of ${repo}" enc="$(urlencode "${branch}")" @@ -91,6 +117,9 @@ if ! info="$(gh api "repos/${repo}/branches/${enc}" 2>&1)"; then fi fail "cannot read ${repo} branch ${branch}: ${info}" fi +# gh follows the redirect for a renamed branch: the name must match exactly. +name="$(jq -r '.name // empty' <<<"${info}")" +[ "${name}" = "${branch}" ] || fail "branch ${branch} in ${repo} resolved to '${name}' (renamed?)" tip="$(jq -r '.commit.sha // empty' <<<"${info}")" # Not `.protected // empty`: jq's // treats false as missing. protected="$(jq -r 'if .protected == null then "" else (.protected | tostring) end' <<<"${info}")" @@ -104,6 +133,11 @@ open_head="$(gh api "repos/${repo}/pulls?state=open&per_page=1&head=$(urlencode open_base="$(gh api "repos/${repo}/pulls?state=open&per_page=1&base=${enc}" --jq 'length')" \ || fail "cannot list open pull requests into ${branch}" [ "${open_base}" = "0" ] || fail "an open pull request in ${repo} targets ${branch}" +for up in ${upstreams}; do + open_up="$(gh api "repos/${up}/pulls?state=open&per_page=1&head=$(urlencode "${owner}:${branch}")" --jq 'length')" \ + || fail "cannot list open pull requests in ${up}" + [ "${open_up}" = "0" ] || fail "an open pull request in ${up} uses ${owner}:${branch} as its head" +done if [ "${mode}" = "dry-run" ]; then echo "dry-run: would delete ${repo} ${branch} at ${tip}." @@ -113,8 +147,16 @@ fi gh api -X DELETE "repos/${repo}/git/refs/heads/${enc}" >/dev/null \ || fail "DELETE refs/heads/${branch} in ${repo} failed" -if gh api "repos/${repo}/branches/${enc}" >/dev/null 2>&1; then - fail "${branch} still exists in ${repo} after the DELETE" -fi +# Only a 404 proves it is gone; any other failure is unknown, not success. A +# short retry covers a read that briefly still sees the branch. +for attempt in 1 2 3; do + if gone="$(gh api "repos/${repo}/branches/${enc}" 2>&1)"; then + [ "${attempt}" -lt 3 ] || fail "${branch} still exists in ${repo} after the DELETE" + sleep "${READBACK_SLEEP:-2}" + continue + fi + grep -q 'HTTP 404' <<<"${gone}" && break + fail "DELETE sent, but the read-back of ${branch} in ${repo} failed: ${gone}" +done echo "Deleted ${repo} ${branch} (was ${tip})." summary "Deleted" "Deleted. To restore it: \`gh api repos/${repo}/git/refs -f ref=refs/heads/${branch} -f sha=${tip}\`." From c7220c0e13731aba9e813c1320b2a727f14518cd Mon Sep 17 00:00:00 2001 From: Rishi Tank Date: Thu, 15 Oct 2026 14:50:00 +0100 Subject: [PATCH 3/5] fix(delete-branch): restore the workflow file, main-only and honest race note The previous commit replaced delete-branch.yml with a placeholder by mistake. This puts back the full workflow, with the review changes: the job only runs from main, and the header says the tip check narrows, but cannot close, the window before the DELETE, and that a fork's parent is checked for open PRs. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01117LuacuBxMJUn1wJkfdyL --- .github/workflows/delete-branch.yml | 86 ++++++++++++++++++++++++++++- 1 file changed, 85 insertions(+), 1 deletion(-) diff --git a/.github/workflows/delete-branch.yml b/.github/workflows/delete-branch.yml index 311c8dd..a8824b4 100644 --- a/.github/workflows/delete-branch.yml +++ b/.github/workflows/delete-branch.yml @@ -1 +1,85 @@ -PLACEHOLDER \ No newline at end of file +# Deletes one named branch in one repository this account owns, by hand. +# +# branch-prune only deletes branches whose pull request merged in the same +# repository at the branch's tip. Some branches can never qualify: a fork's +# branch whose pull request was merged upstream, or a branch whose pull request +# was closed without merging (superseded by a re-cut). This deletes exactly one +# of those, after the checks in scripts/delete-branch.sh: +# - the repository belongs to this account; +# - the branch's tip is exactly the SHA you give. The REST delete has no +# precondition, so this narrows, but cannot close, the seconds-long window +# in which a new push could land and be lost; +# - not the default branch, not a long-lived name (the prune's keep pattern), +# not protected, and no open pull request uses it as its head or its base, +# here or, for a fork, in its parent repository. +# +# DRY RUN BY DEFAULT: `mode` defaults to dry-run, which makes every check and +# reports. Only `mode: apply` deletes. The job summary prints the command that +# restores the branch at the same commit. +# +# TOKEN: the same fine-grained PAT as branch-prune, the repository secret +# PRUNE_TOKEN (Contents: Read and write, Pull requests: Read-only). GITHUB_TOKEN +# only reaches this repository. Every input reaches the shell through env. The +# job only runs from main, so a dispatch on another branch can never run an +# unreviewed copy of the script with this token. +name: delete-branch + +on: + workflow_dispatch: + inputs: + repository: + description: Repository as owner/name. It must belong to this account. + required: true + type: string + branch: + description: Branch to delete. Never the default branch or a long-lived name. + required: true + type: string + sha: + description: Full 40-character commit SHA the branch must point at. + required: true + type: string + mode: + description: dry-run makes every check and reports; apply deletes. + type: choice + options: + - dry-run + - apply + default: dry-run + +permissions: {} + +concurrency: + group: delete-branch-${{ github.repository }} + cancel-in-progress: false + +jobs: + delete: + name: Delete one branch + if: github.ref == 'refs/heads/main' + runs-on: ubuntu-latest + timeout-minutes: 5 + # Checkout only. Every API call uses PRUNE_TOKEN, never GITHUB_TOKEN. + permissions: + contents: read + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Delete the branch + env: + GH_TOKEN: ${{ secrets.PRUNE_TOKEN }} + OWNER: ${{ github.repository_owner }} + REPOSITORY: ${{ inputs.repository }} + BRANCH: ${{ inputs.branch }} + SHA: ${{ inputs.sha }} + MODE: ${{ inputs.mode }} + run: | + set -euo pipefail + if [ -z "${GH_TOKEN}" ]; then + echo "::error title=delete-branch::PRUNE_TOKEN is not set. See branch-prune.yml for how to create it." + exit 1 + fi + bash scripts/delete-branch.sh --owner "${OWNER}" --repo "${REPOSITORY}" \ + --branch "${BRANCH}" --sha "${SHA}" --mode "${MODE}" From b334eb0fbd32514ac11dff094beef1910fb2673d Mon Sep 17 00:00:00 2001 From: Rishi Tank Date: Thu, 15 Oct 2026 15:03:20 +0100 Subject: [PATCH 4/5] fix(delete-branch): check open pull requests across the whole fork network CodeRabbit: a pull request from this branch into a sibling fork lives in that fork's /pulls, which the parent/source REST checks never list. GraphQL Ref.associatedPullRequests(states: OPEN) counts open pull requests whose head is this ref in any repository; a failed query or a missing count refuses the deletion. The REST checks stay as a second source. Also documents each helper function. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01117LuacuBxMJUn1wJkfdyL --- .github/workflows/delete-branch.yml | 4 ++-- scripts/delete-branch.sh | 34 ++++++++++++++++++++++++----- 2 files changed, 30 insertions(+), 8 deletions(-) diff --git a/.github/workflows/delete-branch.yml b/.github/workflows/delete-branch.yml index a8824b4..a12e54d 100644 --- a/.github/workflows/delete-branch.yml +++ b/.github/workflows/delete-branch.yml @@ -10,8 +10,8 @@ # precondition, so this narrows, but cannot close, the seconds-long window # in which a new push could land and be lost; # - not the default branch, not a long-lived name (the prune's keep pattern), -# not protected, and no open pull request uses it as its head or its base, -# here or, for a fork, in its parent repository. +# not protected, and no open pull request uses it as its base here or as its +# head anywhere in the fork network (parent, source or a sibling fork). # # DRY RUN BY DEFAULT: `mode` defaults to dry-run, which makes every check and # reports. Only `mode: apply` deletes. The job summary prints the command that diff --git a/scripts/delete-branch.sh b/scripts/delete-branch.sh index 52bd819..51b36fb 100644 --- a/scripts/delete-branch.sh +++ b/scripts/delete-branch.sh @@ -16,9 +16,13 @@ # 3. the branch is not the repository's default branch and does not match # KEEP_RE, the long-lived pattern prune-merged-branches.sh also keeps; # 4. GitHub does not report the branch as protected; -# 5. no open pull request uses it as its head or as its base, in this -# repository or, for a fork, in its parent and source repositories (where -# a fork branch's pull requests live). +# 5. no open pull request uses it as its head or as its base. The head check +# asks GraphQL for the open pull requests whose head is this ref, in any +# repository of the fork network (the parent, the source or a sibling +# fork), then also lists them over REST in this repository and in its +# parent and source. Pull requests into a branch can only live in the +# branch's own repository, so the base check asks only there. Any query +# that fails refuses the deletion. # A branch that is already gone is reported and is not an error. The default # mode is dry-run: it makes every check and reports what it would delete. # @@ -29,21 +33,25 @@ set -euo pipefail KEEP_RE='^(main|master|develop|staging|test|gh-pages|v[0-9][0-9.]*|deploy-.*|release/.*)$' +# fail MESSAGE: report a refused check in the log and the job summary, exit 1. fail() { echo "::error title=delete-branch::$1" summary "Refused" "$1" exit 1 } +# usage_error MESSAGE: report a malformed command line and exit 2, like the prune. usage_error() { echo "::error title=delete-branch::$1" exit 2 } -# Inputs are echoed into logs and the job summary before they are validated: a -# newline would let a value start its own workflow command line. +# clean VALUE: print VALUE without CR, LF or backticks. Inputs are echoed into +# logs and the job summary before they are validated: a newline would let a +# value start its own workflow command line. clean() { local v="${1//[$'\n\r']/ }"; printf '%s' "${v//\`/\'}"; } +# summary TITLE TEXT: append the inputs and TEXT to the job summary, if any. summary() { if [ -n "${GITHUB_STEP_SUMMARY:-}" ]; then { @@ -59,6 +67,7 @@ summary() { fi } +# urlencode VALUE: percent-encode VALUE for one URL path or query component. urlencode() { jq -rn --arg v "$1" '$v | @uri'; } owner="" repo="" branch="" sha="" mode="dry-run" @@ -104,7 +113,7 @@ full_name="$(jq -r '.full_name // empty' <<<"${meta}")" [ "${full_name,,}" = "${repo,,}" ] || fail "repos/${repo} resolved to '${full_name}' (renamed or transferred?)" default_branch="$(jq -r '.default_branch // empty' <<<"${meta}")" [ -n "${default_branch}" ] || fail "repos/${repo} returned no default branch" -# A fork branch's pull requests live in the fork's parent (and source) repository. +# A fork branch's pull requests usually live in its parent (or source) repository. upstreams="$(jq -r '[.parent.full_name, .source.full_name] | map(select(. != null)) | unique | .[]' <<<"${meta}")" [ "${branch}" != "${default_branch}" ] || fail "${branch} is the default branch of ${repo}" @@ -127,6 +136,19 @@ protected="$(jq -r 'if .protected == null then "" else (.protected | tostring) e [ "${tip}" = "${sha}" ] || fail "${branch} in ${repo} points at ${tip}, not ${sha}: it has moved since you looked" [ "${protected}" = "false" ] || fail "${branch} in ${repo} is protected (protected=${protected:-unknown})" +# Every open pull request whose head is this ref, whichever repository of the +# fork network it was opened in. A sibling fork's /pulls is not listable here. +# shellcheck disable=SC2016 # $owner, $name and $ref are GraphQL variables. +gql='query($owner: String!, $name: String!, $ref: String!) { + repository(owner: $owner, name: $name) { + ref(qualifiedName: $ref) { associatedPullRequests(states: OPEN, first: 1) { totalCount } } + } +}' +open_any="$(gh api graphql -f query="${gql}" -f owner="${repo%%/*}" -f name="${repo#*/}" \ + -f ref="refs/heads/${branch}" --jq '.data.repository.ref.associatedPullRequests.totalCount')" \ + || fail "cannot ask GraphQL for open pull requests from ${branch}" +[[ "${open_any}" =~ ^[0-9]+$ ]] || fail "GraphQL returned no pull request count for ${branch} (got '$(clean "${open_any}")')" +[ "${open_any}" = "0" ] || fail "${open_any} open pull request(s) in the fork network use ${repo}:${branch} as their head (GraphQL)" open_head="$(gh api "repos/${repo}/pulls?state=open&per_page=1&head=$(urlencode "${owner}:${branch}")" --jq 'length')" \ || fail "cannot list open pull requests from ${branch}" [ "${open_head}" = "0" ] || fail "an open pull request in ${repo} uses ${branch} as its head" From 071ec0e534b68cf2326e5e3036a80b7a5077bb11 Mon Sep 17 00:00:00 2001 From: Rishi Tank Date: Thu, 15 Oct 2026 15:11:17 +0100 Subject: [PATCH 5/5] fix(delete-branch): say what GraphQL can see; keep its error text Independent review of b334eb0: GraphQL only counts pull requests in repositories the token can read, so someone else's private sibling fork is invisible. The comments now say so instead of "any repository". A failed GraphQL query now puts gh's error text in the refusal and job summary. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01117LuacuBxMJUn1wJkfdyL --- .github/workflows/delete-branch.yml | 3 ++- scripts/delete-branch.sh | 18 ++++++++++-------- 2 files changed, 12 insertions(+), 9 deletions(-) diff --git a/.github/workflows/delete-branch.yml b/.github/workflows/delete-branch.yml index a12e54d..a074ffc 100644 --- a/.github/workflows/delete-branch.yml +++ b/.github/workflows/delete-branch.yml @@ -11,7 +11,8 @@ # in which a new push could land and be lost; # - not the default branch, not a long-lived name (the prune's keep pattern), # not protected, and no open pull request uses it as its base here or as its -# head anywhere in the fork network (parent, source or a sibling fork). +# head anywhere in the fork network the token can read (parent, source or a +# public sibling fork; someone else's private fork is invisible to it). # # DRY RUN BY DEFAULT: `mode` defaults to dry-run, which makes every check and # reports. Only `mode: apply` deletes. The job summary prints the command that diff --git a/scripts/delete-branch.sh b/scripts/delete-branch.sh index 51b36fb..dc87006 100644 --- a/scripts/delete-branch.sh +++ b/scripts/delete-branch.sh @@ -18,11 +18,12 @@ # 4. GitHub does not report the branch as protected; # 5. no open pull request uses it as its head or as its base. The head check # asks GraphQL for the open pull requests whose head is this ref, in any -# repository of the fork network (the parent, the source or a sibling -# fork), then also lists them over REST in this repository and in its -# parent and source. Pull requests into a branch can only live in the -# branch's own repository, so the base check asks only there. Any query -# that fails refuses the deletion. +# repository of the fork network the token can read (the parent, the +# source or a public sibling fork; someone else's private fork is +# invisible to it), then also lists them over REST in this repository and +# in its parent and source. Pull requests into a branch can only live in +# the branch's own repository, so the base check asks only there. Any +# query that fails refuses the deletion. # A branch that is already gone is reported and is not an error. The default # mode is dry-run: it makes every check and reports what it would delete. # @@ -137,7 +138,8 @@ protected="$(jq -r 'if .protected == null then "" else (.protected | tostring) e [ "${protected}" = "false" ] || fail "${branch} in ${repo} is protected (protected=${protected:-unknown})" # Every open pull request whose head is this ref, whichever repository of the -# fork network it was opened in. A sibling fork's /pulls is not listable here. +# fork network it was opened in, as far as the token can see (someone else's +# private fork is invisible). A sibling fork's /pulls is not listable here. # shellcheck disable=SC2016 # $owner, $name and $ref are GraphQL variables. gql='query($owner: String!, $name: String!, $ref: String!) { repository(owner: $owner, name: $name) { @@ -145,8 +147,8 @@ gql='query($owner: String!, $name: String!, $ref: String!) { } }' open_any="$(gh api graphql -f query="${gql}" -f owner="${repo%%/*}" -f name="${repo#*/}" \ - -f ref="refs/heads/${branch}" --jq '.data.repository.ref.associatedPullRequests.totalCount')" \ - || fail "cannot ask GraphQL for open pull requests from ${branch}" + -f ref="refs/heads/${branch}" --jq '.data.repository.ref.associatedPullRequests.totalCount' 2>&1)" \ + || fail "cannot ask GraphQL for open pull requests from ${branch}: $(clean "${open_any}")" [[ "${open_any}" =~ ^[0-9]+$ ]] || fail "GraphQL returned no pull request count for ${branch} (got '$(clean "${open_any}")')" [ "${open_any}" = "0" ] || fail "${open_any} open pull request(s) in the fork network use ${repo}:${branch} as their head (GraphQL)" open_head="$(gh api "repos/${repo}/pulls?state=open&per_page=1&head=$(urlencode "${owner}:${branch}")" --jq 'length')" \