Skip to content

Delete PR staging and head branches writer #243

Delete PR staging and head branches writer

Delete PR staging and head branches writer #243

name: Delete PR staging and head branches writer
on:
workflow_run:
workflows: ["Delete PR staging and head branches"]
types: [completed]
schedule:
- cron: "5-55/10 * * * *"
workflow_dispatch:
inputs:
pr_number:
description: Pull request number to process
required: true
type: number
permissions:
contents: write
pull-requests: read
jobs:
delete-staging-and-head-branches:
if: ${{ github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' || (github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.event == 'pull_request') }}
runs-on: ubuntu-latest
steps:
- name: Delete staging and head branches
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
REPOSITORY: ${{ github.repository }}
WORKFLOW_RUN_PR_NUMBER: ${{ github.event.workflow_run.pull_requests[0].number }}
DISPATCH_PR_NUMBER: ${{ inputs.pr_number }}
run: |
set -euo pipefail
is_pr_number() {
[[ "$1" =~ ^[0-9]+$ ]]
}
is_staging_branch_for_pr() {
local branch="$1"
local pr_number="$2"
local prefix suffix
is_pr_number "${pr_number}" || return 1
git check-ref-format "refs/heads/${branch}" >/dev/null || return 1
suffix="/advisory-improvement-${pr_number}"
[[ "${branch}" == *"${suffix}" ]] || return 1
prefix="${branch%"${suffix}"}"
[[ -n "${prefix}" && "${prefix}" != */* ]]
}
is_deletable_branch() {
local branch="$1"
[[ -n "${branch}" && "${branch}" != "main" ]] || return 1
git check-ref-format "refs/heads/${branch}" >/dev/null
}
encode_ref() {
jq -rn --arg value "$1" '$value | @uri'
}
request_ref() {
local body_file="$1"
local method="$2"
local encoded_branch="$3"
local ref_path="git/refs"
if [[ "${method}" == "GET" ]]; then
ref_path="git/ref"
fi
curl --silent --show-error \
--request "${method}" \
--output "${body_file}" \
--write-out '%{http_code}' \
--header "Accept: application/vnd.github+json" \
--header "Authorization: Bearer ${GH_TOKEN}" \
--header "X-GitHub-Api-Version: 2022-11-28" \
"https://api.github.com/repos/${REPOSITORY}/${ref_path}/heads/${encoded_branch}"
}
delete_branch() {
local branch="$1"
local expected_sha="${2:-}"
local body_file current_sha encoded_branch status
encoded_branch="$(encode_ref "${branch}")"
body_file="$(mktemp)"
if ! status="$(request_ref "${body_file}" GET "${encoded_branch}")"; then
rm -f "${body_file}"
echo "::error::Failed to inspect branch ${branch}."
return 1
fi
if [[ "${status}" == "404" ]]; then
rm -f "${body_file}"
echo "Branch ${branch} is already absent."
return 0
fi
if [[ "${status}" != "200" ]]; then
cat "${body_file}" >&2
rm -f "${body_file}"
echo "::error::Failed to inspect branch ${branch}: GitHub API returned ${status}."
return 1
fi
current_sha="$(jq -er '.object.sha' "${body_file}")"
if [[ -n "${expected_sha}" && "${current_sha}" != "${expected_sha}" ]]; then
rm -f "${body_file}"
echo "::error::Head branch ${branch} now points to ${current_sha}, not ${expected_sha}; leaving it and the staging branch in place."
return 1
fi
if ! status="$(request_ref "${body_file}" DELETE "${encoded_branch}")"; then
rm -f "${body_file}"
echo "::error::Failed to delete branch ${branch}."
return 1
fi
if [[ "${status}" == "204" ]]; then
rm -f "${body_file}"
echo "Deleted branch ${branch}."
return 0
fi
if [[ "${status}" == "404" ]]; then
rm -f "${body_file}"
echo "Branch ${branch} was already absent when deletion was attempted."
return 0
fi
cat "${body_file}" >&2
rm -f "${body_file}"
echo "::error::Failed to delete branch ${branch}: GitHub API returned ${status}."
return 1
}
process_pr() {
local advisory_file_pages base_ref base_repo expected_staging_branch head_ref head_repo head_sha
local pr_json pr_number="$1" state
expected_staging_branch="${2:-}"
if ! is_pr_number "${pr_number}"; then
echo "::error::Unexpected pull request number: ${pr_number}"
return 1
fi
pr_json="$(gh api "repos/${REPOSITORY}/pulls/${pr_number}")"
state="$(jq -r '.state' <<<"${pr_json}")"
base_ref="$(jq -r '.base.ref' <<<"${pr_json}")"
base_repo="$(jq -r '.base.repo.full_name' <<<"${pr_json}")"
head_ref="$(jq -r '.head.ref // empty' <<<"${pr_json}")"
head_repo="$(jq -r '.head.repo.full_name // empty' <<<"${pr_json}")"
head_sha="$(jq -r '.head.sha // empty' <<<"${pr_json}")"
if [[ "${state}" != "closed" ]]; then
echo "Pull request ${pr_number} is ${state}, not closed; skipping."
return 0
fi
if [[ "${base_repo}" != "${REPOSITORY}" ]]; then
echo "Pull request ${pr_number} targets ${base_repo}, not ${REPOSITORY}; skipping."
return 0
fi
if [[ -n "${expected_staging_branch}" && "${base_ref}" != "${expected_staging_branch}" ]]; then
echo "Pull request ${pr_number} no longer targets ${expected_staging_branch}; skipping."
return 0
fi
if ! is_staging_branch_for_pr "${base_ref}" "${pr_number}"; then
echo "Pull request ${pr_number} base branch ${base_ref} is not its advisory improvement branch; skipping."
return 0
fi
if [[ "${head_repo}" != "${REPOSITORY}" ]]; then
echo "Pull request ${pr_number} head repo is ${head_repo}, not ${REPOSITORY}; skipping."
return 0
fi
if [[ ! "${head_sha}" =~ ^[0-9a-f]{40}$ ]]; then
echo "::error::Pull request ${pr_number} has an unexpected head SHA: ${head_sha}"
return 1
fi
advisory_file_pages="$(gh api --paginate "repos/${REPOSITORY}/pulls/${pr_number}/files?per_page=100" \
--jq 'any(.[]; .filename | startswith("advisories/"))')"
if ! grep -qx 'true' <<<"${advisory_file_pages}"; then
echo "Pull request ${pr_number} does not modify advisories/; skipping."
return 0
fi
if [[ "${head_ref}" == "${base_ref}" ]]; then
delete_branch "${base_ref}" "${head_sha}"
return 0
fi
if ! is_deletable_branch "${head_ref}"; then
echo "::error::Head branch ${head_ref} is not a valid deletable Git branch."
return 1
fi
delete_branch "${head_ref}" "${head_sha}"
delete_branch "${base_ref}"
}
collect_reconciliation_targets() {
local branch branches
branches="$(gh api --paginate "repos/${REPOSITORY}/branches?per_page=100" --jq '.[].name')"
while IFS= read -r branch; do
if [[ "${branch}" =~ ^[^/]+/advisory-improvement-([0-9]+)$ ]] &&
git check-ref-format "refs/heads/${branch}" >/dev/null; then
printf '%s\t%s\n' "${BASH_REMATCH[1]}" "${branch}"
fi
done <<<"${branches}"
}
if [[ "${GITHUB_EVENT_NAME}" == "workflow_run" ]]; then
PR_NUMBER="${WORKFLOW_RUN_PR_NUMBER}"
if ! is_pr_number "${PR_NUMBER:-}"; then
echo "No pull request number was provided; skipping."
exit 0
fi
process_pr "${PR_NUMBER}"
elif [[ "${GITHUB_EVENT_NAME}" == "workflow_dispatch" ]]; then
process_pr "${DISPATCH_PR_NUMBER}"
else
TARGETS="$(collect_reconciliation_targets)"
if [[ -z "${TARGETS}" ]]; then
echo "No staging branches need reconciliation."
exit 0
fi
while IFS=$'\t' read -r PR_NUMBER STAGING_BRANCH; do
process_pr "${PR_NUMBER}" "${STAGING_BRANCH}"
done <<<"${TARGETS}"
fi