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
43 changes: 43 additions & 0 deletions .github/rulesets/libresign-github-ci.json
Original file line number Diff line number Diff line change
@@ -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
}
}
]
}
22 changes: 18 additions & 4 deletions docs/ruleset-sync.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
77 changes: 64 additions & 13 deletions scripts/sync-rulesets.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -91,25 +104,33 @@ 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 = (
.bypass_actors
+ [{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

Expand All @@ -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
}

Expand Down Expand Up @@ -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)
}
'
}
Expand All @@ -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=$?
Expand Down Expand Up @@ -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 "$@"

Expand Down
49 changes: 38 additions & 11 deletions tests/ruleset-policy.bats
Original file line number Diff line number Diff line change
Expand Up @@ -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 ]
}
Loading