diff --git a/.github/rulesets/libresign-github-ci.json b/.github/rulesets/libresign-github-ci.json new file mode 100644 index 0000000..5822c5d --- /dev/null +++ b/.github/rulesets/libresign-github-ci.json @@ -0,0 +1,43 @@ +{ + "name": "Require LibreSign .github CI", + "target": "branch", + "enforcement": "active", + "bypass_actors": [ + { + "actor_id": 0, + "actor_type": "OrganizationAdmin", + "bypass_mode": "pull_request" + } + ], + "conditions": { + "ref_name": { + "include": [ + "~DEFAULT_BRANCH" + ], + "exclude": [] + } + }, + "rules": [ + { + "type": "required_status_checks", + "parameters": { + "required_status_checks": [ + { + "context": "Ruleset sync behavior and policy" + }, + { + "context": "ShellCheck" + }, + { + "context": "actionlint" + }, + { + "context": "zizmor" + } + ], + "strict_required_status_checks_policy": true, + "do_not_enforce_on_create": false + } + } + ] +} diff --git a/docs/ruleset-sync.md b/docs/ruleset-sync.md index dcf75fe..e8e42b3 100644 --- a/docs/ruleset-sync.md +++ b/docs/ruleset-sync.md @@ -2,17 +2,31 @@ This repository keeps branch protection rulesets consistent across public repositories in the LibreSign and LibreCodeCoop organizations. -## Source of truth +## Policy composition -`.github/rulesets/default-branches.json` defines the desired ruleset for default branches and `stable*` branches. +Ruleset policies are composed in two layers so repository-specific requirements do not make the shared policy harder to understand. -The synchronization script applies this policy to public, non-archived repositories available to the ruleset GitHub App installation. +### Base policy + +`.github/rulesets/default-branches.json` applies to every public, non-archived repository managed by the synchronization. + +It protects the default branch and `stable*` branches. This is also the policy that receives the Nextcloud translation exception described below. + +### Repository-specific policies + +Additional ruleset files are applied only to repositories explicitly selected by `ruleset_files_for_repository()` in `scripts/sync-rulesets.sh`. + +Currently, `.github/rulesets/libresign-github-ci.json` applies only to `LibreSign/.github` and only to its default branch. It requires the repository's Bats, ShellCheck, actionlint, and zizmor checks to pass before merge. + +Repository-specific policies must stay separate from the base policy unless the rule is intended for all managed repositories. ## Nextcloud apps A repository is treated as a Nextcloud app when `appinfo/info.xml` exists in its default branch. -For these repositories, the synchronization adds `nextcloud-bot` as a bypass actor with `bypass_mode: always`. The GitHub actor is pinned by user ID `20296731`. +For these repositories, the synchronization adds `nextcloud-bot` as a bypass actor with `bypass_mode: always` to the base ruleset. Because the base ruleset protects both the default branch and `stable*`, translation pushes keep working on maintained stable branches as well. + +The GitHub actor is pinned by user ID `20296731`. A `404` while checking `appinfo/info.xml` means the repository is not a Nextcloud app. Other API errors abort synchronization so transient failures cannot silently remove the bot bypass. diff --git a/scripts/sync-rulesets.sh b/scripts/sync-rulesets.sh index 6ded420..93241b5 100755 --- a/scripts/sync-rulesets.sh +++ b/scripts/sync-rulesets.sh @@ -2,6 +2,7 @@ set -euo pipefail DEFAULT_RULESET_FILE=".github/rulesets/default-branches.json" +LIBRESIGN_GITHUB_CI_RULESET_FILE=".github/rulesets/libresign-github-ci.json" NEXTCLOUD_BOT="nextcloud-bot" NEXTCLOUD_BOT_ID="20296731" CHECK_ONLY=false @@ -70,6 +71,18 @@ list_repositories() { .name' } +ruleset_files_for_repository() { + local repo="$1" + + printf '%s\n' "$DEFAULT_RULESET_FILE" + + case "$ORG/$repo" in + LibreSign/.github) + printf '%s\n' "$LIBRESIGN_GITHUB_CI_RULESET_FILE" + ;; + esac +} + is_nextcloud_app() { local repo="$1" local error_file @@ -91,10 +104,18 @@ is_nextcloud_app() { build_ruleset() { local repo="$1" - local detection_status=0 + local ruleset_file="${2:-${RULESET_FILE:-$DEFAULT_RULESET_FILE}}" + local ruleset_name detection_status=0 + + ruleset_name="$(jq -r '.name' "$ruleset_file")" + + if [ "$ruleset_name" != "Protect default and stable branches" ]; then + cat "$ruleset_file" + return + fi if is_nextcloud_app "$repo"; then - echo "Nextcloud app detected; allowing $NEXTCLOUD_BOT to bypass the ruleset" >&2 + echo "Nextcloud app detected; allowing $NEXTCLOUD_BOT to bypass the base ruleset" >&2 jq \ --argjson bot_id "$NEXTCLOUD_BOT_ID" \ '.bypass_actors = ( @@ -102,14 +123,14 @@ build_ruleset() { + [{actor_id: $bot_id, actor_type: "User", bypass_mode: "always"}] | unique_by([.actor_type, .actor_id]) )' \ - "$RULESET_FILE" + "$ruleset_file" return else detection_status=$? fi if [ "$detection_status" -eq 1 ]; then - cat "$RULESET_FILE" + cat "$ruleset_file" return fi @@ -118,8 +139,10 @@ build_ruleset() { find_ruleset_id() { local repo="$1" + local ruleset_name="$2" + gh api "repos/$ORG/$repo/rulesets" | - jq -r --arg name "$RULESET_NAME" '.[] | select(.name == $name) | .id' | + jq -r --arg name "$ruleset_name" '.[] | select(.name == $name) | .id' | head -n 1 } @@ -152,10 +175,22 @@ normalize_ruleset() { required_review_thread_resolution: .parameters.required_review_thread_resolution } } + elif .type == "required_status_checks" then + { + type, + parameters: { + required_status_checks: [ + .parameters.required_status_checks[] | + {context} + ] | sort_by(.context), + strict_required_status_checks_policy: .parameters.strict_required_status_checks_policy, + do_not_enforce_on_create: .parameters.do_not_enforce_on_create + } + } else {type} end - ] + ] | sort_by(.type) } ' } @@ -176,22 +211,24 @@ ruleset_has_drift() { [ "$current" != "$desired" ] } -sync_repository() { +sync_ruleset_file() { local repo="$1" - local desired_file ruleset_id drift_status status + local ruleset_file="$2" + local desired_file ruleset_id ruleset_name drift_status status - echo "=== $ORG/$repo ===" + ruleset_name="$(jq -r '.name' "$ruleset_file")" + echo "Policy: $ruleset_name" desired_file="$(mktemp)" TEMP_FILES+=("$desired_file") - if build_ruleset "$repo" > "$desired_file"; then + if build_ruleset "$repo" "$ruleset_file" > "$desired_file"; then : else status=$? return "$status" fi - if ruleset_id="$(find_ruleset_id "$repo")"; then + if ruleset_id="$(find_ruleset_id "$repo" "$ruleset_name")"; then : else status=$? @@ -243,10 +280,24 @@ sync_repository() { --input "$desired_file" } +sync_repository() { + local repo="$1" + local ruleset_file failed=0 + + echo "=== $ORG/$repo ===" + + while read -r ruleset_file; do + [ -n "$ruleset_file" ] || continue + if ! sync_ruleset_file "$repo" "$ruleset_file"; then + failed=1 + fi + done < <(ruleset_files_for_repository "$repo") + + return "$failed" +} + main() { ORG="${ORG:?ORG must be set}" - RULESET_FILE="${RULESET_FILE:-$DEFAULT_RULESET_FILE}" - RULESET_NAME="$(jq -r '.name' "$RULESET_FILE")" parse_args "$@" diff --git a/tests/ruleset-policy.bats b/tests/ruleset-policy.bats index 02e7609..b858cdc 100644 --- a/tests/ruleset-policy.bats +++ b/tests/ruleset-policy.bats @@ -2,51 +2,78 @@ setup() { REPO_ROOT="$(cd "$BATS_TEST_DIRNAME/.." && pwd)" - RULESET_FILE="$REPO_ROOT/.github/rulesets/default-branches.json" + BASE_RULESET_FILE="$REPO_ROOT/.github/rulesets/default-branches.json" + GITHUB_CI_RULESET_FILE="$REPO_ROOT/.github/rulesets/libresign-github-ci.json" } -@test "ruleset JSON is valid" { - run jq -e . "$RULESET_FILE" +@test "managed ruleset JSON files are valid" { + run jq -e . "$BASE_RULESET_FILE" "$GITHUB_CI_RULESET_FILE" [ "$status" -eq 0 ] } -@test "ruleset is active and protects default and stable branches" { +@test "base ruleset is active and protects default and stable branches" { run jq -e ' .enforcement == "active" and (.conditions.ref_name.include | index("~DEFAULT_BRANCH") != null) and (.conditions.ref_name.include | index("refs/heads/stable*") != null) - ' "$RULESET_FILE" + ' "$BASE_RULESET_FILE" [ "$status" -eq 0 ] } -@test "ruleset prevents deletion and non-fast-forward updates" { +@test "base ruleset prevents deletion and non-fast-forward updates" { run jq -e ' ([.rules[].type] | index("deletion") != null) and ([.rules[].type] | index("non_fast_forward") != null) - ' "$RULESET_FILE" + ' "$BASE_RULESET_FILE" [ "$status" -eq 0 ] } -@test "pull requests require approval, CODEOWNERS and resolved threads" { +@test "base pull request policy requires approval, CODEOWNERS and resolved threads" { run jq -e ' .rules[] | select(.type == "pull_request") | .parameters.required_approving_review_count >= 1 and .parameters.require_code_owner_review == true and .parameters.required_review_thread_resolution == true - ' "$RULESET_FILE" + ' "$BASE_RULESET_FILE" [ "$status" -eq 0 ] } -@test "organization admins can bypass only through pull requests" { +@test "organization admins can bypass base policy only through pull requests" { run jq -e ' [.bypass_actors[] | select(.actor_type == "OrganizationAdmin" and .bypass_mode == "pull_request") ] | length == 1 - ' "$RULESET_FILE" + ' "$BASE_RULESET_FILE" + + [ "$status" -eq 0 ] +} + +@test ".github CI policy applies only to the default branch" { + run jq -e ' + .enforcement == "active" and + .conditions.ref_name.include == ["~DEFAULT_BRANCH"] and + .conditions.ref_name.exclude == [] + ' "$GITHUB_CI_RULESET_FILE" + + [ "$status" -eq 0 ] +} + +@test ".github CI policy requires every security and test check" { + run jq -e ' + .rules[] | + select(.type == "required_status_checks") | + .parameters.strict_required_status_checks_policy == true and + ([.parameters.required_status_checks[].context] | sort) == ([ + "Ruleset sync behavior and policy", + "ShellCheck", + "actionlint", + "zizmor" + ] | sort) + ' "$GITHUB_CI_RULESET_FILE" [ "$status" -eq 0 ] } diff --git a/tests/sync-rulesets.bats b/tests/sync-rulesets.bats index 0041c81..4704908 100644 --- a/tests/sync-rulesets.bats +++ b/tests/sync-rulesets.bats @@ -7,18 +7,19 @@ setup() { ORG="LibreSign" RULESET_FILE="$REPO_ROOT/.github/rulesets/default-branches.json" - RULESET_NAME="$(jq -r '.name' "$RULESET_FILE")" + DEFAULT_RULESET_FILE="$RULESET_FILE" + LIBRESIGN_GITHUB_CI_RULESET_FILE="$REPO_ROOT/.github/rulesets/libresign-github-ci.json" CHECK_ONLY=false TARGET_REPOSITORY="" TEMP_FILES=() } -@test "Nextcloud apps receive the pinned nextcloud-bot bypass" { +@test "Nextcloud apps receive the pinned nextcloud-bot bypass in the base ruleset" { gh() { return 0 } - result="$(build_ruleset libresign 2>/dev/null)" + result="$(build_ruleset libresign "$DEFAULT_RULESET_FILE" 2>/dev/null)" [ "$(jq '[.bypass_actors[] | select(.actor_type == "User" and .actor_id == 20296731 and .bypass_mode == "always")] | length' <<< "$result")" -eq 1 ] } @@ -29,18 +30,46 @@ setup() { return 1 } - result="$(build_ruleset docs)" + result="$(build_ruleset docs "$DEFAULT_RULESET_FILE")" [ "$(jq '[.bypass_actors[] | select(.actor_type == "User" and .actor_id == 20296731)] | length' <<< "$result")" -eq 0 ] } +@test "repository-specific CI ruleset does not run Nextcloud detection" { + gh() { + echo "unexpected gh call" >&2 + return 99 + } + + run build_ruleset .github "$LIBRESIGN_GITHUB_CI_RULESET_FILE" + + [ "$status" -eq 0 ] + [ "$(jq -r '.name' <<< "$output")" = "Require LibreSign .github CI" ] +} + +@test "LibreSign .github composes the base and repository-specific CI rulesets" { + run ruleset_files_for_repository .github + + [ "$status" -eq 0 ] + [ "${lines[0]}" = "$DEFAULT_RULESET_FILE" ] + [ "${lines[1]}" = "$LIBRESIGN_GITHUB_CI_RULESET_FILE" ] + [ "${#lines[@]}" -eq 2 ] +} + +@test "ordinary repositories receive only the base ruleset" { + run ruleset_files_for_repository documentation + + [ "$status" -eq 0 ] + [ "$output" = "$DEFAULT_RULESET_FILE" ] +} + @test "unexpected errors while detecting a Nextcloud app fail closed" { gh() { echo "gh: Resource not accessible (HTTP 403)" >&2 return 1 } - run build_ruleset libresign + run build_ruleset libresign "$DEFAULT_RULESET_FILE" [ "$status" -eq 2 ] [[ "$output" == *"Failed to detect whether LibreSign/libresign is a Nextcloud app"* ]] @@ -52,7 +81,7 @@ setup() { return 1 } - run build_ruleset libresign + run build_ruleset libresign "$DEFAULT_RULESET_FILE" [ "$status" -eq 2 ] } @@ -61,14 +90,13 @@ setup() { fixture="$(mktemp)" TEMP_FILES+=("$fixture") jq '.bypass_actors += [{actor_id: 20296731, actor_type: "User", bypass_mode: "always"}]' \ - "$RULESET_FILE" > "$fixture" - RULESET_FILE="$fixture" + "$DEFAULT_RULESET_FILE" > "$fixture" gh() { return 0 } - result="$(build_ruleset libresign 2>/dev/null)" + result="$(build_ruleset libresign "$fixture" 2>/dev/null)" [ "$(jq '[.bypass_actors[] | select(.actor_type == "User" and .actor_id == 20296731)] | length' <<< "$result")" -eq 1 ] } @@ -91,8 +119,8 @@ setup() { [ "$output" = "libresign" ] } -@test "normalization ignores GitHub API defaults not managed by this policy" { - desired="$(normalize_ruleset < "$RULESET_FILE")" +@test "normalization ignores GitHub API defaults not managed by the base policy" { + desired="$(normalize_ruleset < "$DEFAULT_RULESET_FILE")" current="$( jq ' .bypass_actors[0].actor_id = null | @@ -101,8 +129,15 @@ setup() { dismissal_restriction: {enabled: false, allowed_actors: []}, require_extra_approval_for_unattributed_changes: true } - ' "$RULESET_FILE" | normalize_ruleset + ' "$DEFAULT_RULESET_FILE" | normalize_ruleset )" [ "$current" = "$desired" ] } + +@test "normalization preserves required status check policy" { + normalized="$(normalize_ruleset < "$LIBRESIGN_GITHUB_CI_RULESET_FILE")" + + [ "$(jq '[.rules[] | select(.type == "required_status_checks") | .parameters.required_status_checks[]] | length' <<< "$normalized")" -eq 4 ] + [ "$(jq -r '.rules[] | select(.type == "required_status_checks") | .parameters.strict_required_status_checks_policy' <<< "$normalized")" = "true" ] +}