Delete PR staging and head branches writer #240
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 |