Skip to content
Merged
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
117 changes: 101 additions & 16 deletions .devtools/repo-settings.sh
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,13 @@
# no opinion about is left as it is. A ruleset that exists on a repository but is not
# listed for it is reported and never deleted.
#
# What GitHub does not offer a repository on its plan is skipped with a note, and is
# not a difference: on the Free plan a private repository has no rulesets, no secret
# scanning and no private vulnerability reporting.
#
# Needs gh, authenticated as an admin of the repositories, and jq.
set -euo pipefail
# Exit codes: 0 in step (or applied), 1 differences found by --check, 2 anything else.
set -Eeuo pipefail

org="${ORG:-studiobimo}"
root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
Expand All @@ -42,6 +47,39 @@ fi

differences=0

die() {
echo "✖ $*" >&2
exit 2
}

# Exit 1 means "differs" to callers, so a command that fails must not end in it.
trap 'die "repo-settings.sh failed at line ${LINENO}"' ERR

work="$(mktemp -d)"
trap 'rm -rf "${work}"' EXIT

# get <gh api arguments...>: the answer in $body and its HTTP status in $http, 200
# for any success. gh prints an error's JSON on stdout as well, so $body holds the
# message when the request was refused.
get() {
http=200
gh api "$@" >"${work}/stdout" 2>"${work}/stderr" || http=""
body="$(cat "${work}/stdout")"
[[ -z "${http}" ]] || return 0
http="$(sed -n 's/.*(HTTP \([0-9][0-9]*\)).*/\1/p' "${work}/stderr" | tail -n 1)"
[[ -n "${http}" ]] || die "gh api $1: $(cat "${work}/stderr")"
}

# refused <path>: the last get was not answered, and nothing here expects that.
refused() {
die "GET $1: HTTP ${http}: $(jq -r '.message? // empty' <<<"${body}" 2>/dev/null || true)"
}

# skip <what>: GitHub does not offer it to this repository.
skip() {
echo " ! $1: not offered to this repository on its plan (skipped)"
}

# changes <current-json> <desired-json>: one line per key of desired that differs.
changes() {
jq -rn --argjson c "$1" --argjson d "$2" '
Expand Down Expand Up @@ -91,31 +129,76 @@ for repo in "${repos[@]}"; do
echo "${org}/${repo}"
before="${differences}"

reconcile "settings" "$(gh api "${api}")" "$(jq -c .repository "${settings}")" \
gh api -X PATCH "${api}"
reconcile "actions" "$(gh api "${api}/actions/permissions")" "$(jq -c .actions "${settings}")" \
get "${api}"
[[ "${http}" == 200 ]] || refused "${api}"
current="${body}"
desired="$(jq -c .repository "${settings}")"
# Without the features the key is null. It goes in the same request as the merge
# settings, so it is left out rather than risk that request being refused.
if jq -e '.security_and_analysis == null' <<<"${current}" >/dev/null \
&& jq -e 'has("security_and_analysis")' <<<"${desired}" >/dev/null; then
skip "secret scanning"
desired="$(jq -c 'del(.security_and_analysis)' <<<"${desired}")"
fi
reconcile "settings" "${current}" "${desired}" gh api -X PATCH "${api}"

get "${api}/actions/permissions"
[[ "${http}" == 200 ]] || refused "${api}/actions/permissions"
reconcile "actions" "${body}" "$(jq -c .actions "${settings}")" \
gh api -X PUT "${api}/actions/permissions"
reconcile "workflow permissions" "$(gh api "${api}/actions/permissions/workflow")" \
"$(jq -c .workflow "${settings}")" gh api -X PUT "${api}/actions/permissions/workflow"

get "${api}/actions/permissions/workflow"
[[ "${http}" == 200 ]] || refused "${api}/actions/permissions/workflow"
reconcile "workflow permissions" "${body}" "$(jq -c .workflow "${settings}")" \
gh api -X PUT "${api}/actions/permissions/workflow"

# This endpoint answers with a status code, not a body: 204 is on, 404 is off.
alerts=false
if gh api "${api}/vulnerability-alerts" >/dev/null 2>&1; then alerts=true; fi
get "${api}/vulnerability-alerts"
case "${http}" in
200) alerts=true ;;
404) alerts=false ;;
*) refused "${api}/vulnerability-alerts" ;;
esac
toggle "vulnerability alerts" "${api}/vulnerability-alerts" \
"$(jq -r .vulnerability_alerts "${settings}")" "${alerts}"

# 404 here is "not enabled", and the alerts above have to be on first.
get "${api}/automated-security-fixes"
case "${http}" in
200) fixes="$(jq -r .enabled <<<"${body}")" ;;
404) fixes=false ;;
*) refused "${api}/automated-security-fixes" ;;
esac
toggle "automated security fixes" "${api}/automated-security-fixes" \
"$(jq -r .automated_security_fixes "${settings}")" \
"$(gh api "${api}/automated-security-fixes" --jq .enabled 2>/dev/null || echo false)"
toggle "private vulnerability reporting" "${api}/private-vulnerability-reporting" \
"$(jq -r .private_vulnerability_reporting "${settings}")" \
"$(gh api "${api}/private-vulnerability-reporting" --jq .enabled 2>/dev/null || echo false)"
"$(jq -r .automated_security_fixes "${settings}")" "${fixes}"

# A repository that can have it answers 200 either way, so 404 is "not offered".
get "${api}/private-vulnerability-reporting"
case "${http}" in
200)
toggle "private vulnerability reporting" "${api}/private-vulnerability-reporting" \
"$(jq -r .private_vulnerability_reporting "${settings}")" "$(jq -r .enabled <<<"${body}")"
;;
404) skip "private vulnerability reporting" ;;
*) refused "${api}/private-vulnerability-reporting" ;;
esac

existing="$(gh api "${api}/rulesets" --paginate --jq '[.[] | select(.source_type == "Repository") | {name, id}]')"
get "${api}/rulesets" --paginate
if [[ "${http}" == 403 ]] && jq -e '.message | test("^Upgrade to ")' <<<"${body}" >/dev/null 2>&1; then
skip "rulesets"
if [[ "${differences}" == "${before}" ]]; then
echo " ✔ in step"
fi
continue
fi
[[ "${http}" == 200 ]] || refused "${api}/rulesets"
# --paginate prints one array per page.
existing="$(jq -cs '[.[][] | select(.source_type == "Repository") | {name, id}]' <<<"${body}")"
wanted="$(jq -c --arg r "${repo}" '.repositories[$r] // .default' "${assignments}")"

while IFS= read -r name; do
file="${root}/rulesets/${name}.json"
[[ -f "${file}" ]] || { echo "settings/rulesets.json names ${name}, but rulesets/${name}.json is missing" >&2; exit 2; }
[[ -f "${file}" ]] || die "settings/rulesets.json names ${name}, but rulesets/${name}.json is missing"
desired="$(jq -c "${comparable}" "${file}")"
id="$(jq -r --arg n "${name}" '.[] | select(.name == $n) | .id' <<<"${existing}")"
if [[ -z "${id}" ]]; then
Expand All @@ -126,7 +209,9 @@ for repo in "${repos[@]}"; do
fi
continue
fi
current="$(gh api "${api}/rulesets/${id}" --jq "${comparable}")"
get "${api}/rulesets/${id}"
[[ "${http}" == 200 ]] || refused "${api}/rulesets/${id}"
current="$(jq -c "${comparable}" <<<"${body}")"
extra="$(jq -rn --argjson c "${current}" --argjson d "${desired}" \
'($c.rules | keys) - ($d.rules | keys) | map(" rule \(.): not in the file") | .[]')"
found="$(changes "${current}" "${desired}")"
Expand Down
175 changes: 175 additions & 0 deletions .devtools/test-repo-settings.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
#!/usr/bin/env bash
# Tests repo-settings.sh against a stand-in for gh that answers from files, so nothing
# here reaches GitHub. Run: bash .devtools/test-repo-settings.sh
set -euo pipefail

here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
root="$(cd "${here}/.." && pwd)"
failures=0

work="$(mktemp -d)"
trap 'rm -rf "${work}"' EXIT

# The stand-in. A GET of <path> prints ${GH_STUB}/<path with / as _>.json. A file
# beside it ending in .status holds "<code> <message>" and turns the answer into the
# failure gh reports: the body on stdout, the message on stderr, exit 1. Any other
# method is recorded in ${GH_STUB}/calls with its input, and succeeds.
mkdir "${work}/bin"
cat >"${work}/bin/gh" <<'STUB'
#!/usr/bin/env bash
set -euo pipefail
[[ "$1" == api ]] || { echo "the stand-in only knows gh api" >&2; exit 64; }
shift
method=GET path="" input=""
while [[ $# -gt 0 ]]; do
case "$1" in
-X) method="$2"; shift 2 ;;
--input) input="$2"; shift 2 ;;
--paginate) shift ;;
*) path="$1"; shift ;;
esac
done
if [[ "${method}" != GET ]]; then
echo "${method} ${path} $([[ -z "${input}" ]] || jq -c .)" >>"${GH_STUB}/calls"
exit 0
fi
file="${GH_STUB}/${path//\//_}"
if [[ -f "${file}.status" ]]; then
read -r code message <"${file}.status"
jq -cn --arg m "${message}" --arg s "${code}" '{message: $m, status: $s}'
echo "gh: ${message} (HTTP ${code})" >&2
exit 1
fi
[[ -f "${file}.json" ]] || { echo "the stand-in has no answer for ${path}" >&2; exit 64; }
cat "${file}.json"
STUB
chmod +x "${work}/bin/gh"
export PATH="${work}/bin:${PATH}"

settings="${root}/settings/repository.json"

# fixture <dir> <repo> public|private: a repository already in step with settings/.
# A private one answers as GitHub does on the Free plan.
fixture() {
local dir="$1" repo="$2" kind="$3" base id=0 name
base="${dir}/repos_studiobimo_${repo}"
mkdir -p "${dir}"
jq -c .repository "${settings}" >"${base}.json"
jq -c .actions "${settings}" >"${base}_actions_permissions.json"
jq -c .workflow "${settings}" >"${base}_actions_permissions_workflow.json"
: >"${base}_vulnerability-alerts.json"
echo '{"enabled":true}' >"${base}_automated-security-fixes.json"
if [[ "${kind}" == private ]]; then
jq -c '.repository | .security_and_analysis = null' "${settings}" >"${base}.json"
echo "404 Not Found" >"${base}_private-vulnerability-reporting.status"
echo "403 Upgrade to GitHub Pro or make this repository public to enable this feature." \
>"${base}_rulesets.status"
return
fi
echo '{"enabled":true}' >"${base}_private-vulnerability-reporting.json"
echo '[]' >"${base}_rulesets.json"
while IFS= read -r name; do
id=$((id + 1))
jq -c --argjson id "${id}" '. + {id: $id, source_type: "Repository"}' \
"${root}/rulesets/${name}.json" >"${base}_rulesets_${id}.json"
jq -c --slurpfile r "${base}_rulesets_${id}.json" '. + $r' "${base}_rulesets.json" \
>"${base}_rulesets.json.new"
mv "${base}_rulesets.json.new" "${base}_rulesets.json"
done < <(jq -r '.default[]' "${root}/settings/rulesets.json")
}

# run <dir> <arguments...>: the output in $out, the exit code in $code.
run() {
local dir="$1"
shift
code=0
out="$(GH_STUB="${dir}" bash "${here}/repo-settings.sh" "$@" 2>&1)" || code=$?
}

check() {
if "${@:2}"; then
echo "✔ $1"
else
echo "✖ $1" >&2
echo " ${out//$'\n'/$'\n' }" >&2
failures=$((failures + 1))
fi
}

exits() { [[ "${code}" == "$1" ]]; }
says() { grep -qF -- "$1" <<<"${out}"; }
silent_on() { ! grep -qF -- "$1" <<<"${out}"; }
called() { grep -qE -- "$1" "$2/calls" 2>/dev/null; }
never_called() { ! called "$@"; }

echo "a public repository"
d="${work}/public"
fixture "${d}" app public
run "${d}" --check app
check "in step exits 0" exits 0
check "and says so" says "✔ in step"
check "and skips nothing" silent_on "(skipped)"

rm "${d}/repos_studiobimo_app_rulesets_1.json"
jq -c 'del(.[0])' "${d}/repos_studiobimo_app_rulesets.json" >"${d}/r" && mv "${d}/r" "${d}/repos_studiobimo_app_rulesets.json"
run "${d}" --check app
check "a missing ruleset is a difference: --check exits 1" exits 1
check "and changes nothing" never_called . "${d}"
run "${d}" app
check "apply creates it and exits 0" exits 0
check "with a POST" called '^POST repos/studiobimo/app/rulesets ' "${d}"

echo "a private repository on the Free plan"
d="${work}/private"
fixture "${d}" secret private
run "${d}" --check secret
check "what the plan lacks is not a difference: --check exits 0" exits 0
check "skips the rulesets, with a note" says "! rulesets: not offered"
check "skips secret scanning" says "! secret scanning: not offered"
check "skips private vulnerability reporting" says "! private vulnerability reporting: not offered"
check "prints no error body" silent_on '"message"'

jq -c '.delete_branch_on_merge = false' "${d}/repos_studiobimo_secret.json" >"${d}/r" && mv "${d}/r" "${d}/repos_studiobimo_secret.json"
run "${d}" --check secret
check "a real difference still exits 1" exits 1
run "${d}" secret
check "apply exits 0" exits 0
check "patches the settings" called '^PATCH repos/studiobimo/secret \{.*"delete_branch_on_merge":true' "${d}"
check "without the secret scanning it cannot have" never_called 'security_and_analysis' "${d}"
check "and writes no ruleset" never_called 'rulesets' "${d}"

echo "several repositories"
d="${work}/both"
fixture "${d}" a-secret private
fixture "${d}" b-app public
run "${d}" --check a-secret b-app
check "a private one does not stop the run" says "studiobimo/b-app"
check "exits 0 when both are in step" exits 0

echo "failures"
d="${work}/broken"
fixture "${d}" app public
rm "${d}/repos_studiobimo_app_rulesets.json"
echo "500 Server Error" >"${d}/repos_studiobimo_app_rulesets.status"
run "${d}" --check app
check "a refused request exits 2, not 1" exits 2
check "and names it" says "GET repos/studiobimo/app/rulesets: HTTP 500"

d="${work}/forbidden"
fixture "${d}" app public
rm "${d}/repos_studiobimo_app_rulesets.json"
echo "403 Resource not accessible by personal access token" >"${d}/repos_studiobimo_app_rulesets.status"
run "${d}" --check app
check "a 403 that is not about the plan exits 2" exits 2

d="${work}/gone"
mkdir "${d}"
echo "404 Not Found" >"${d}/repos_studiobimo_nope.status"
run "${d}" --check nope
check "a repository that does not exist exits 2" exits 2

if ((failures > 0)); then
echo "${failures} failed" >&2
exit 1
fi
echo "All passed"
4 changes: 4 additions & 0 deletions .github/workflows/self-lint.yml
Original file line number Diff line number Diff line change
Expand Up @@ -69,3 +69,7 @@ jobs:
# hooks call them with none, and that path is tested here.
- name: Test the check scripts
run: bash .devtools/test-hooks.sh

# Against a stand-in for gh, so it needs no token and reaches no repository.
- name: Test the repository settings script
run: bash .devtools/test-repo-settings.sh
2 changes: 2 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,8 @@ SHA and open a draft PR there.
The check scripts have their own test, `bash .devtools/test-hooks.sh`, which `self-lint` runs. It
also covers `pr-checks/lint.mjs`, so run `npm ci --prefix .github/actions/pr-checks --ignore-scripts`
first.
`repo-settings.sh` has its own too, `bash .devtools/test-repo-settings.sh`, run by `self-lint`
against a stand-in for `gh`: it never reaches GitHub, so check a change with `--check` as well.
They are called two ways — by `ci-pr` with explicit arguments and by the git hooks with none — so
a change to one has to keep both working. Try the shared hooks end to end from a consumer checkout
by pointing its `remotes` entry at your branch: `ref: <branch>` with `refetch: true`.
Expand Down
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -237,6 +237,11 @@ It needs `gh` signed in as an admin of the repositories. Only keys written in `s
compared, and a ruleset that is on a repository but not listed for it is reported, never deleted.
To add one ruleset by hand instead: Settings → Rules → Rulesets → New ruleset → Import a ruleset.

A private repository gets less. On the Free plan GitHub offers it no rulesets, no secret scanning
and no private vulnerability reporting, so the script skips those with a note and applies the
rest: its default branch is not protected. A skip is not a difference. The script exits 0 when
everything is in step or applied, 1 when `--check` found differences, and 2 on any failure.

These are repository rulesets applied one repo at a time, because organization-wide rulesets
are not available on the Free plan.

Expand Down Expand Up @@ -267,6 +272,7 @@ mise install # every tool, at the versions in mise.toml and m
mise exec -- lefthook install # wire the git hooks
npm ci --prefix .github/actions/pr-checks --ignore-scripts # the commitlint ci-pr installs
bash .devtools/test-hooks.sh # the check scripts behind ci-pr and the git hooks
bash .devtools/test-repo-settings.sh # repo-settings.sh, against a stand-in for gh
```

[mise](https://mise.jdx.dev/getting-started.html) is the one thing to install by hand. It pins
Expand Down
Loading