From 7c8f71fcb7c3e92412899b6ca000c1c33f483592 Mon Sep 17 00:00:00 2001 From: david morales <118213696+davidmoralesz@users.noreply.github.com> Date: Fri, 9 Oct 2026 12:57:20 -0600 Subject: [PATCH] fix(devtools): skip what a private repository's plan does not offer repo-settings.sh stopped at the first private repository: on the Free plan GitHub refuses its rulesets with a 403, and the script exited 1, which callers read as "differs". Rulesets, secret scanning and private vulnerability reporting are now skipped with a note when GitHub does not offer them, and a skip is not a difference. Any request that fails for another reason exits 2. Closes #56 --- .devtools/repo-settings.sh | 117 ++++++++++++++++++--- .devtools/test-repo-settings.sh | 175 ++++++++++++++++++++++++++++++++ .github/workflows/self-lint.yml | 4 + AGENTS.md | 2 + README.md | 6 ++ 5 files changed, 288 insertions(+), 16 deletions(-) create mode 100755 .devtools/test-repo-settings.sh diff --git a/.devtools/repo-settings.sh b/.devtools/repo-settings.sh index 0ef5551..9d03a35 100755 --- a/.devtools/repo-settings.sh +++ b/.devtools/repo-settings.sh @@ -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)" @@ -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 : 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 : 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 : GitHub does not offer it to this repository. +skip() { + echo " ! $1: not offered to this repository on its plan (skipped)" +} + # changes : one line per key of desired that differs. changes() { jq -rn --argjson c "$1" --argjson d "$2" ' @@ -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 @@ -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}")" diff --git a/.devtools/test-repo-settings.sh b/.devtools/test-repo-settings.sh new file mode 100755 index 0000000..d89424e --- /dev/null +++ b/.devtools/test-repo-settings.sh @@ -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 prints ${GH_STUB}/.json. A file +# beside it ending in .status holds " " 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 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 : 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" diff --git a/.github/workflows/self-lint.yml b/.github/workflows/self-lint.yml index 8ee150e..2fb313a 100644 --- a/.github/workflows/self-lint.yml +++ b/.github/workflows/self-lint.yml @@ -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 diff --git a/AGENTS.md b/AGENTS.md index e4d5d92..ba7a708 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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: ` with `refetch: true`. diff --git a/README.md b/README.md index 9853968..bee13d4 100644 --- a/README.md +++ b/README.md @@ -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. @@ -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