|
| 1 | +name: Delete PR staging and head branches writer |
| 2 | + |
| 3 | +on: |
| 4 | + workflow_run: |
| 5 | + workflows: ["Delete PR staging and head branches"] |
| 6 | + types: [completed] |
| 7 | + schedule: |
| 8 | + - cron: "5-55/10 * * * *" |
| 9 | + workflow_dispatch: |
| 10 | + inputs: |
| 11 | + pr_number: |
| 12 | + description: Pull request number to process |
| 13 | + required: true |
| 14 | + type: number |
| 15 | + |
| 16 | +permissions: |
| 17 | + contents: write |
| 18 | + pull-requests: read |
| 19 | + |
| 20 | +jobs: |
| 21 | + delete-staging-and-head-branches: |
| 22 | + if: ${{ github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' || (github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.event == 'pull_request') }} |
| 23 | + runs-on: ubuntu-latest |
| 24 | + steps: |
| 25 | + - name: Delete staging and head branches |
| 26 | + env: |
| 27 | + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} |
| 28 | + REPOSITORY: ${{ github.repository }} |
| 29 | + WORKFLOW_RUN_PR_NUMBER: ${{ github.event.workflow_run.pull_requests[0].number }} |
| 30 | + DISPATCH_PR_NUMBER: ${{ inputs.pr_number }} |
| 31 | + run: | |
| 32 | + set -euo pipefail |
| 33 | +
|
| 34 | + is_pr_number() { |
| 35 | + [[ "$1" =~ ^[0-9]+$ ]] |
| 36 | + } |
| 37 | +
|
| 38 | + is_staging_branch_for_pr() { |
| 39 | + local branch="$1" |
| 40 | + local pr_number="$2" |
| 41 | + local prefix suffix |
| 42 | +
|
| 43 | + is_pr_number "${pr_number}" || return 1 |
| 44 | + git check-ref-format "refs/heads/${branch}" >/dev/null || return 1 |
| 45 | +
|
| 46 | + suffix="/advisory-improvement-${pr_number}" |
| 47 | + [[ "${branch}" == *"${suffix}" ]] || return 1 |
| 48 | + prefix="${branch%"${suffix}"}" |
| 49 | + [[ -n "${prefix}" && "${prefix}" != */* ]] |
| 50 | + } |
| 51 | +
|
| 52 | + is_deletable_branch() { |
| 53 | + local branch="$1" |
| 54 | + [[ -n "${branch}" && "${branch}" != "main" ]] || return 1 |
| 55 | + git check-ref-format "refs/heads/${branch}" >/dev/null |
| 56 | + } |
| 57 | +
|
| 58 | + encode_ref() { |
| 59 | + jq -rn --arg value "$1" '$value | @uri' |
| 60 | + } |
| 61 | +
|
| 62 | + request_ref() { |
| 63 | + local body_file="$1" |
| 64 | + local method="$2" |
| 65 | + local encoded_branch="$3" |
| 66 | + local ref_path="git/refs" |
| 67 | +
|
| 68 | + if [[ "${method}" == "GET" ]]; then |
| 69 | + ref_path="git/ref" |
| 70 | + fi |
| 71 | +
|
| 72 | + curl --silent --show-error \ |
| 73 | + --request "${method}" \ |
| 74 | + --output "${body_file}" \ |
| 75 | + --write-out '%{http_code}' \ |
| 76 | + --header "Accept: application/vnd.github+json" \ |
| 77 | + --header "Authorization: Bearer ${GH_TOKEN}" \ |
| 78 | + --header "X-GitHub-Api-Version: 2022-11-28" \ |
| 79 | + "https://api.github.com/repos/${REPOSITORY}/${ref_path}/heads/${encoded_branch}" |
| 80 | + } |
| 81 | +
|
| 82 | + delete_branch() { |
| 83 | + local branch="$1" |
| 84 | + local expected_sha="${2:-}" |
| 85 | + local body_file current_sha encoded_branch status |
| 86 | +
|
| 87 | + encoded_branch="$(encode_ref "${branch}")" |
| 88 | + body_file="$(mktemp)" |
| 89 | + if ! status="$(request_ref "${body_file}" GET "${encoded_branch}")"; then |
| 90 | + rm -f "${body_file}" |
| 91 | + echo "::error::Failed to inspect branch ${branch}." |
| 92 | + return 1 |
| 93 | + fi |
| 94 | +
|
| 95 | + if [[ "${status}" == "404" ]]; then |
| 96 | + rm -f "${body_file}" |
| 97 | + echo "Branch ${branch} is already absent." |
| 98 | + return 0 |
| 99 | + fi |
| 100 | + if [[ "${status}" != "200" ]]; then |
| 101 | + cat "${body_file}" >&2 |
| 102 | + rm -f "${body_file}" |
| 103 | + echo "::error::Failed to inspect branch ${branch}: GitHub API returned ${status}." |
| 104 | + return 1 |
| 105 | + fi |
| 106 | +
|
| 107 | + current_sha="$(jq -er '.object.sha' "${body_file}")" |
| 108 | + if [[ -n "${expected_sha}" && "${current_sha}" != "${expected_sha}" ]]; then |
| 109 | + rm -f "${body_file}" |
| 110 | + echo "::error::Head branch ${branch} now points to ${current_sha}, not ${expected_sha}; leaving it and the staging branch in place." |
| 111 | + return 1 |
| 112 | + fi |
| 113 | +
|
| 114 | + if ! status="$(request_ref "${body_file}" DELETE "${encoded_branch}")"; then |
| 115 | + rm -f "${body_file}" |
| 116 | + echo "::error::Failed to delete branch ${branch}." |
| 117 | + return 1 |
| 118 | + fi |
| 119 | +
|
| 120 | + if [[ "${status}" == "204" ]]; then |
| 121 | + rm -f "${body_file}" |
| 122 | + echo "Deleted branch ${branch}." |
| 123 | + return 0 |
| 124 | + fi |
| 125 | + if [[ "${status}" == "404" ]]; then |
| 126 | + rm -f "${body_file}" |
| 127 | + echo "Branch ${branch} was already absent when deletion was attempted." |
| 128 | + return 0 |
| 129 | + fi |
| 130 | +
|
| 131 | + cat "${body_file}" >&2 |
| 132 | + rm -f "${body_file}" |
| 133 | + echo "::error::Failed to delete branch ${branch}: GitHub API returned ${status}." |
| 134 | + return 1 |
| 135 | + } |
| 136 | +
|
| 137 | + process_pr() { |
| 138 | + local advisory_file_pages base_ref base_repo expected_staging_branch head_ref head_repo head_sha |
| 139 | + local pr_json pr_number="$1" state |
| 140 | + expected_staging_branch="${2:-}" |
| 141 | +
|
| 142 | + if ! is_pr_number "${pr_number}"; then |
| 143 | + echo "::error::Unexpected pull request number: ${pr_number}" |
| 144 | + return 1 |
| 145 | + fi |
| 146 | +
|
| 147 | + pr_json="$(gh api "repos/${REPOSITORY}/pulls/${pr_number}")" |
| 148 | + state="$(jq -r '.state' <<<"${pr_json}")" |
| 149 | + base_ref="$(jq -r '.base.ref' <<<"${pr_json}")" |
| 150 | + base_repo="$(jq -r '.base.repo.full_name' <<<"${pr_json}")" |
| 151 | + head_ref="$(jq -r '.head.ref // empty' <<<"${pr_json}")" |
| 152 | + head_repo="$(jq -r '.head.repo.full_name // empty' <<<"${pr_json}")" |
| 153 | + head_sha="$(jq -r '.head.sha // empty' <<<"${pr_json}")" |
| 154 | +
|
| 155 | + if [[ "${state}" != "closed" ]]; then |
| 156 | + echo "Pull request ${pr_number} is ${state}, not closed; skipping." |
| 157 | + return 0 |
| 158 | + fi |
| 159 | +
|
| 160 | + if [[ "${base_repo}" != "${REPOSITORY}" ]]; then |
| 161 | + echo "Pull request ${pr_number} targets ${base_repo}, not ${REPOSITORY}; skipping." |
| 162 | + return 0 |
| 163 | + fi |
| 164 | +
|
| 165 | + if [[ -n "${expected_staging_branch}" && "${base_ref}" != "${expected_staging_branch}" ]]; then |
| 166 | + echo "Pull request ${pr_number} no longer targets ${expected_staging_branch}; skipping." |
| 167 | + return 0 |
| 168 | + fi |
| 169 | +
|
| 170 | + if ! is_staging_branch_for_pr "${base_ref}" "${pr_number}"; then |
| 171 | + echo "Pull request ${pr_number} base branch ${base_ref} is not its advisory improvement branch; skipping." |
| 172 | + return 0 |
| 173 | + fi |
| 174 | +
|
| 175 | + if [[ "${head_repo}" != "${REPOSITORY}" ]]; then |
| 176 | + echo "Pull request ${pr_number} head repo is ${head_repo}, not ${REPOSITORY}; skipping." |
| 177 | + return 0 |
| 178 | + fi |
| 179 | +
|
| 180 | + if [[ ! "${head_sha}" =~ ^[0-9a-f]{40}$ ]]; then |
| 181 | + echo "::error::Pull request ${pr_number} has an unexpected head SHA: ${head_sha}" |
| 182 | + return 1 |
| 183 | + fi |
| 184 | +
|
| 185 | + advisory_file_pages="$(gh api --paginate "repos/${REPOSITORY}/pulls/${pr_number}/files?per_page=100" \ |
| 186 | + --jq 'any(.[]; .filename | startswith("advisories/"))')" |
| 187 | + if ! grep -qx 'true' <<<"${advisory_file_pages}"; then |
| 188 | + echo "Pull request ${pr_number} does not modify advisories/; skipping." |
| 189 | + return 0 |
| 190 | + fi |
| 191 | +
|
| 192 | + if [[ "${head_ref}" == "${base_ref}" ]]; then |
| 193 | + delete_branch "${base_ref}" "${head_sha}" |
| 194 | + return 0 |
| 195 | + fi |
| 196 | +
|
| 197 | + if ! is_deletable_branch "${head_ref}"; then |
| 198 | + echo "::error::Head branch ${head_ref} is not a valid deletable Git branch." |
| 199 | + return 1 |
| 200 | + fi |
| 201 | +
|
| 202 | + delete_branch "${head_ref}" "${head_sha}" |
| 203 | + delete_branch "${base_ref}" |
| 204 | + } |
| 205 | +
|
| 206 | + collect_reconciliation_targets() { |
| 207 | + local branch branches |
| 208 | +
|
| 209 | + branches="$(gh api --paginate "repos/${REPOSITORY}/branches?per_page=100" --jq '.[].name')" |
| 210 | + while IFS= read -r branch; do |
| 211 | + if [[ "${branch}" =~ ^[^/]+/advisory-improvement-([0-9]+)$ ]] && |
| 212 | + git check-ref-format "refs/heads/${branch}" >/dev/null; then |
| 213 | + printf '%s\t%s\n' "${BASH_REMATCH[1]}" "${branch}" |
| 214 | + fi |
| 215 | + done <<<"${branches}" |
| 216 | + } |
| 217 | +
|
| 218 | + if [[ "${GITHUB_EVENT_NAME}" == "workflow_run" ]]; then |
| 219 | + PR_NUMBER="${WORKFLOW_RUN_PR_NUMBER}" |
| 220 | + if ! is_pr_number "${PR_NUMBER:-}"; then |
| 221 | + echo "No pull request number was provided; skipping." |
| 222 | + exit 0 |
| 223 | + fi |
| 224 | + process_pr "${PR_NUMBER}" |
| 225 | + elif [[ "${GITHUB_EVENT_NAME}" == "workflow_dispatch" ]]; then |
| 226 | + process_pr "${DISPATCH_PR_NUMBER}" |
| 227 | + else |
| 228 | + TARGETS="$(collect_reconciliation_targets)" |
| 229 | + if [[ -z "${TARGETS}" ]]; then |
| 230 | + echo "No staging branches need reconciliation." |
| 231 | + exit 0 |
| 232 | + fi |
| 233 | + while IFS=$'\t' read -r PR_NUMBER STAGING_BRANCH; do |
| 234 | + process_pr "${PR_NUMBER}" "${STAGING_BRANCH}" |
| 235 | + done <<<"${TARGETS}" |
| 236 | + fi |
0 commit comments