Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

chore: add script to reconcile branch protection #204

Closed
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 15 additions & 7 deletions branch-protections.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,9 @@ checks when trying to search without typing.
Use the helper script to get a mostly-concreate set of values
which may be set to provide check-based branch merge protection.

The list of checks is discovered through the check suites published
by GitHub Actions against the latest pull request made by a human.

List checks for all GeoNet repos

```sh
Expand All @@ -39,13 +42,6 @@ Some example output may look like
```yaml
GeoNet/Actions:
- commit-digest-vet / presubmit-workflow
- conform/commit/commit-body
- conform/commit/conventional-commit
- conform/commit/header-case
- conform/commit/header-last-character
- conform/commit/header-length
- conform/commit/imperative-mood
- conform/commit/spellcheck
- conform / conform
- lint-markdown / markdown-lint
- presubmit-readme-toc / presubmit-readme-toc
Expand All @@ -70,4 +66,16 @@ GeoNet/Actions:
- t9-no-push-check
- validate-schema / validate-github-actions
GeoNet/base-images:
- conform / conform
- presubmit-github-actions-workflow-validator / validate-github-actions
- presubmit-image-documented
- presubmit-image-exists
- presubmit-image-format
- presubmit-readme-toc / presubmit-readme-toc
```

Protection rules can be applied directly from what checks are present in the latest PR with

```sh
./hack/list-checks.sh Actions base-images | ./hack/set-checks.sh
```
78 changes: 52 additions & 26 deletions hack/list-checks.sh
Original file line number Diff line number Diff line change
Expand Up @@ -4,42 +4,51 @@ set -o errexit
set -o nounset
set -o pipefail

REPOS="${@}"
ORG="${GH_ORG:-GeoNet}"
REPOS="${*}"

DEBUG=false
# given DEBUG set to true, log special outputs
__debug_echo() {
if [ ! "$DEBUG" = true ]; then
if [ ! "${DEBUG:-false}" = true ]; then
return
fi
echo "${@}"
}

# return a list under the ORG of repos with GitHub Actions workflows
get_repos_with_actions() {
repos=($(gh api orgs/GeoNet/repos --jq '.[] | select(.fork==false) | select(.archived==false) | .name' --paginate \
repos=($(gh api "orgs/$ORG/repos" --jq '.[] | select(.fork==false) | select(.archived==false) | .name' --paginate \
| sort \
| tr ' ' '\n' \
| xargs -I{} \
sh -c 'gh api "repos/GeoNet/{}/contents/.github/workflows" --jq ". | length | . > 0" 2>&1>/dev/null && echo GeoNet/{}' \
| grep -E '^GeoNet/.*' | cat))
sh -c "gh api \"repos/$ORG/{}/contents/.github/workflows\" --jq \". | length | . > 0\" 2>&1>/dev/null && echo $ORG/{}" \
| grep -E "^$ORG/.*" | cat))
echo "${repos[@]}"
}

# given a repo and offset, return the number of the latest merged PR made by a human
get_pull_request_numbers() {
REPO="$1"
PULL_REQUEST_NUMBERS=()
while read NUMBER; do
PULL_REQUEST_NUMBERS+=("$NUMBER")
done < <(gh api -X GET "repos/$REPO/pulls" -f state=all --jq .[0].number)
echo "${PULL_REQUEST_NUMBERS[@]}"
LIST_OFFSET="${2:-1}"
NUMBERS="$(gh api -X GET "repos/$REPO/pulls" -f state=closed \
--jq '.[] | select(.merged_at!=null) | select(.user.Bot!="type") | select(.user.login!="github-actions[bot]") | select(.user.login!="dependabot[bot]") | .number')"
if [ -z "$NUMBERS" ]; then
echo 0
return
fi
echo "$NUMBERS" | head -n"${LIST_OFFSET}" | tail -n1
}

# given a repo and a PR number, return the latest commit digest
get_head_ref_commit() {
REPO="$1"
NUMBER="$2"
commit="$(gh api "repos/$REPO/pulls/$NUMBER/commits" --jq '.[0].sha')"
commit="$(gh api "repos/$REPO/pulls/$NUMBER/commits" --jq 'last(. | to_entries[]) | .value.sha')"
echo "$commit"
}

# given a repo, return status checks
# NOTE not currently used
get_status_checks() {
REPO="$1"
checks=()
Expand All @@ -54,29 +63,46 @@ get_status_checks() {
CHECKS+=("${checks[@]}")
}

# given a repo, return a list of workflow checks
get_workflow_checks() {
REPO="$1"
checks=()
for PR in $(get_pull_request_numbers "$REPO"); do
__debug_echo "$REPO/pull/$PR"
COMMIT="$(get_head_ref_commit "$REPO" "$PR")"
__debug_echo " - PR commit: $COMMIT"
while read SUITE; do
__debug_echo " - Check suite: $SUITE"
while read RUN; do
__debug_echo " - Check run: $RUN"
checks+=("$RUN")
done < <(gh api "repos/$REPO/check-suites/$SUITE/check-runs" --jq .check_runs[].name | sed 's/(.*) //' | grep -vi travis)
done < <(gh api "repos/$REPO/commits/$COMMIT/check-suites" --jq .check_suites[].id)
PR_NUMBER_OFFSET=1
HAS_CHECKS=false
until [ "${HAS_CHECKS:-false}" = true ]; do
for PR in $(get_pull_request_numbers "$REPO" "$PR_NUMBER_OFFSET"); do
# exit get_workflow_checks if
# - there are no PRs for the repo
# - up to five earlier than the latest PR still have no checks
if [ "$PR" = "0" ] || [ "$PR_NUMBER_OFFSET" = "5" ]; then
break 2
fi
__debug_echo "$REPO/pull/$PR"
COMMIT="$(get_head_ref_commit "$REPO" "$PR")"
__debug_echo " - PR commit: $COMMIT"
while read SUITE; do
__debug_echo " - Check suite: $SUITE"
while read RUN; do
__debug_echo " - Check run: $RUN"
checks+=("$RUN")
done < <(gh api "repos/$REPO/check-suites/$SUITE/check-runs" --jq .check_runs[].name | grep -vi travis)
done < <(gh api "repos/$REPO/commits/$COMMIT/check-suites" --jq .check_suites[].id)
done
# if no checks are found, try one earlier than the latest PR
if [ "$(echo "${checks[@]}" | tr ' ' '\n' | wc -l)" = "1" ]; then
PR_NUMBER_OFFSET=$((PR_NUMBER_OFFSET+=1))
continue
fi
HAS_CHECKS=true
CHECKS+=("${checks[@]}")
done
CHECKS+=("${checks[@]}")
}

# given a repo, return a list of checks
get_checks() {
REPO="$1"
printf "$REPO:"
CHECKS=()
get_status_checks "$REPO"
get_workflow_checks "$REPO"
if [[ -z ${CHECKS[*]} ]]; then
echo ' []'
Expand All @@ -92,7 +118,7 @@ get_checks() {

if [ -n "$REPOS" ]; then
for REPO in $REPOS; do
get_checks "GeoNet/$REPO"
get_checks "$ORG/$REPO"
done
exit $?
fi
Expand Down
68 changes: 68 additions & 0 deletions hack/set-checks.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
#!/bin/bash

set -o errexit
set -o nounset
set -o pipefail

# NOTE Input must be from stdin and formatted like
# ORG/REPO:
# - check1
# - check2
INPUT="$(< /dev/stdin yq e)"
GHA_APPID="15368" # github actions integrates with github through github app integrations

APPLY="${1:-do-not-apply}"

for REPO in $(echo "$INPUT" | yq e '. | keys | .[]'); do
export REPO="$REPO" # for yq env
CHECKS="$(echo "$INPUT" | yq e '.[env(REPO)]' -o json | jq -rcM)"
echo "$REPO : $CHECKS"

ORG="$(gh api repos/$REPO --jq '.owner.login')"
DEFAULT_BRANCH="$(gh api "repos/$REPO" --jq .default_branch)"
if ! gh api "repos/$REPO/branches/$DEFAULT_BRANCH/protection" ; then
UPDATED_CONFIG="$(jq -rcnM --arg CHECKS "$CHECKS" --arg ORG "$ORG" --arg GHA_APPID "$GHA_APPID" \
'{
"required_status_checks": {
"strict": true,
"checks": [($CHECKS | fromjson | .[] | {"context":.,"app_id":($GHA_APPID | tonumber)})]
},
"restrictions": {"users":[], "teams":[], "apps":[]},
"enforce_admins": null,
"required_pull_request_reviews": null
}')"
else
EXISTING_CONFIG="$(gh api "repos/$REPO/branches/$DEFAULT_BRANCH/protection" | jq -rcM)"
# NOTE removes lots of fields from the original, since the api rejects them as non-null values.
# instead of constructing a new json object, it's based off of the current SOW
# to retain other fields that we don't care about in this update that might
# be in the original values.
UPDATED_CONFIG="$(echo "$EXISTING_CONFIG" \
| jq -rcM --arg CHECKS "$CHECKS" --arg ORG "$ORG" --arg GHA_APPID "$GHA_APPID" \
'.required_status_checks = {"strict":true, "checks": [($CHECKS | fromjson | .[] | {"context":.,"app_id":($GHA_APPID | tonumber)})]} |
.restrictions = {"users":[], "teams":[], "apps":[]} |
.enforce_admins=null | .required_signatures=null | .required_linear_history=null | .allow_deletions=null | .block_creations=null |
.required_conversation_resolution=null | .lock_branch=null | .allow_fork_syncing=null | .url=null | .required_pull_request_reviews=null | .allow_force_pushes=null
')"
fi

echo "Config difference:"
sdiff <(echo "$EXISTING_CONFIG" | jq) <(echo "$UPDATED_CONFIG" | jq) || true

if [ ! "$APPLY" = "apply-and-agree-to-risk" ]; then
echo "NOTE: dry run enabled"
echo "WARNING: applying may change unintended settings regarding branch protection for the target branch"
echo "to apply, use: $0 apply-and-agree-to-risk"
continue
fi

# NOTE gh api doesn't support this functionality
echo "Updating branch protection for $REPO on branch $DEFAULT_BRANCH"
curl -L \
-X PUT \
-H "Accept: application/vnd.github+json" \
-H "Authorization: Bearer $(gh auth token)" \
-H "X-GitHub-Api-Version: 2022-11-28" \
"https://api.github.com/repos/$REPO/branches/$DEFAULT_BRANCH/protection" \
-d "$UPDATED_CONFIG"
done