diff --git a/branch-protections.md b/branch-protections.md index 1443f19..f8d09ed 100644 --- a/branch-protections.md +++ b/branch-protections.md @@ -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 @@ -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 @@ -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 ``` diff --git a/hack/list-checks.sh b/hack/list-checks.sh index bc4e22e..629101f 100755 --- a/hack/list-checks.sh +++ b/hack/list-checks.sh @@ -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=() @@ -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 ' []' @@ -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 diff --git a/hack/set-checks.sh b/hack/set-checks.sh new file mode 100755 index 0000000..1741fa2 --- /dev/null +++ b/hack/set-checks.sh @@ -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