diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..7bb64f3 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,12 @@ +version: 2 +updates: + - package-ecosystem: github-actions + directory: / + schedule: + interval: weekly + cooldown: + default-days: 7 + groups: + github-actions: + patterns: + - "*" diff --git a/.github/workflows/actionlint.yml b/.github/workflows/actionlint.yml new file mode 100644 index 0000000..7a92aab --- /dev/null +++ b/.github/workflows/actionlint.yml @@ -0,0 +1,28 @@ +name: actionlint + +on: + pull_request: + push: + branches: + - main + +permissions: + contents: read + +jobs: + actionlint: + name: actionlint + runs-on: ubuntu-latest + timeout-minutes: 10 + + steps: + - name: Checkout + uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5 + with: + persist-credentials: false + + - name: Run actionlint + uses: raven-actions/actionlint@3d39aea434753780c3b3d4a1a31c854b4dbf49d7 # v2.2.0 + with: + version: 1.7.12 + shellcheck: true diff --git a/.github/workflows/bats-ruleset-sync.yml b/.github/workflows/bats-ruleset-sync.yml new file mode 100644 index 0000000..3d5d375 --- /dev/null +++ b/.github/workflows/bats-ruleset-sync.yml @@ -0,0 +1,30 @@ +name: Bats ruleset sync tests + +on: + pull_request: + push: + branches: + - main + +permissions: + contents: read + +jobs: + bats: + name: Ruleset sync behavior and policy + runs-on: ubuntu-latest + timeout-minutes: 10 + + steps: + - name: Checkout + uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5 + with: + persist-credentials: false + + - name: Install Bats + run: | + sudo apt-get update + sudo apt-get install --yes bats + + - name: Run Bats + run: bats tests diff --git a/.github/workflows/shellcheck.yml b/.github/workflows/shellcheck.yml new file mode 100644 index 0000000..464b7f4 --- /dev/null +++ b/.github/workflows/shellcheck.yml @@ -0,0 +1,25 @@ +name: ShellCheck + +on: + pull_request: + push: + branches: + - main + +permissions: + contents: read + +jobs: + shellcheck: + name: ShellCheck + runs-on: ubuntu-latest + timeout-minutes: 10 + + steps: + - name: Checkout + uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5 + with: + persist-credentials: false + + - name: Run ShellCheck + run: shellcheck scripts/*.sh diff --git a/.github/workflows/sync-rulesets.yml b/.github/workflows/sync-rulesets.yml index e4443a8..912c8c6 100644 --- a/.github/workflows/sync-rulesets.yml +++ b/.github/workflows/sync-rulesets.yml @@ -8,18 +8,53 @@ on: permissions: contents: read +concurrency: + group: sync-rulesets + cancel-in-progress: false + jobs: + discover: + name: Discover public repositories + runs-on: ubuntu-latest + timeout-minutes: 10 + outputs: + repositories: ${{ steps.repositories.outputs.repositories }} + + steps: + - name: List repositories + id: repositories + env: + GH_TOKEN: ${{ github.token }} + run: | + repositories="$( + for organization in LibreSign LibreCodeCoop; do + gh api \ + --paginate \ + "orgs/$organization/repos?type=public&per_page=100" \ + --jq '.[] | select(.archived == false) | [.owner.login, .name] | @tsv' + done | + jq -Rsc ' + split("\n") | + map( + select(length > 0) | + split("\t") | + {organization: .[0], repository: .[1]} + ) + ' + )" + echo "repositories=$repositories" >> "$GITHUB_OUTPUT" + sync: - name: Sync rulesets for ${{ matrix.organization }} + name: Sync ${{ matrix.organization }}/${{ matrix.repository }} + needs: discover runs-on: ubuntu-latest + timeout-minutes: 10 environment: ruleset-sync strategy: fail-fast: false matrix: - organization: - - LibreSign - - LibreCodeCoop + include: ${{ fromJSON(needs.discover.outputs.repositories) }} steps: - name: Checkout @@ -27,17 +62,19 @@ jobs: with: persist-credentials: false - - name: Generate GitHub App token + - name: Generate repository-scoped GitHub App token id: app-token uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3 with: client-id: ${{ vars.RULESET_APP_CLIENT_ID }} private-key: ${{ secrets.RULESET_APP_PRIVATE_KEY }} owner: ${{ matrix.organization }} + repositories: ${{ matrix.repository }} permission-administration: write - - name: Sync rulesets + - name: Sync ruleset env: GH_TOKEN: ${{ steps.app-token.outputs.token }} ORG: ${{ matrix.organization }} - run: ./scripts/sync-rulesets.sh + TARGET_REPOSITORY: ${{ format('{0}/{1}', matrix.organization, matrix.repository) }} + run: ./scripts/sync-rulesets.sh --repo "$TARGET_REPOSITORY" diff --git a/.github/workflows/zizmor.yml b/.github/workflows/zizmor.yml new file mode 100644 index 0000000..23a6742 --- /dev/null +++ b/.github/workflows/zizmor.yml @@ -0,0 +1,29 @@ +name: zizmor + +on: + pull_request: + push: + branches: + - main + +permissions: + contents: read + +jobs: + zizmor: + name: zizmor + runs-on: ubuntu-latest + timeout-minutes: 10 + + steps: + - name: Checkout + uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5 + with: + persist-credentials: false + + - name: Run zizmor + uses: zizmorcore/zizmor-action@cc914d7f3750a2d13d75c7f184a1060aa0e9d482 # v0.6.4 + with: + version: 1.30.1 + advanced-security: false + online-audits: false diff --git a/README.md b/README.md index a46ae92..f933749 100644 --- a/README.md +++ b/README.md @@ -1 +1,17 @@ -# .github \ No newline at end of file +# LibreSign organization automation + +This repository centralizes shared GitHub organization automation for LibreSign and LibreCodeCoop. + +It helps keep repository governance consistent as the project grows, including branch protection policies, controlled exceptions required by project workflows, and automated validation of the configuration that manages those policies. + +The repository currently delivers: + +- consistent branch protection rules across public repositories; +- automatic support for Nextcloud translation workflows where required; +- reduced administrative access scope for automation; +- automated checks that help prevent regressions in repository governance. + +Technical and operational details are kept in [`docs/`](docs/): + +- [Ruleset synchronization](docs/ruleset-sync.md) +- [Testing and validation](docs/testing.md) diff --git a/docs/ruleset-sync.md b/docs/ruleset-sync.md new file mode 100644 index 0000000..dcf75fe --- /dev/null +++ b/docs/ruleset-sync.md @@ -0,0 +1,45 @@ +# Ruleset synchronization + +This repository keeps branch protection rulesets consistent across public repositories in the LibreSign and LibreCodeCoop organizations. + +## Source of truth + +`.github/rulesets/default-branches.json` defines the desired ruleset for default branches and `stable*` branches. + +The synchronization script applies this policy to public, non-archived repositories available to the ruleset GitHub App installation. + +## 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`. + +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. + +## Running locally + +Synchronize all public, non-archived repositories in an organization: + +```bash +ORG=LibreSign ./scripts/sync-rulesets.sh +``` + +Check for drift without modifying repositories: + +```bash +ORG=LibreSign ./scripts/sync-rulesets.sh --check +``` + +Limit synchronization to one repository: + +```bash +ORG=LibreSign ./scripts/sync-rulesets.sh --repo LibreSign/libresign +``` + +The command requires `gh`, `jq`, and a GitHub token with repository administration permission. + +## Privileged workflow + +`.github/workflows/sync-rulesets.yml` discovers public repositories with the read-only workflow token. It then creates one short-lived GitHub App token per repository, scoped to that repository with `administration: write`, and runs the sync using `--repo`. + +The workflow serializes synchronization runs and has execution timeouts to reduce the risk of concurrent administrative writes or stuck privileged jobs. diff --git a/docs/testing.md b/docs/testing.md new file mode 100644 index 0000000..513550c --- /dev/null +++ b/docs/testing.md @@ -0,0 +1,35 @@ +# Testing and validation + +The repository uses focused checks so each validation type has its own setup and failure signal. + +## Bats + +Bats tests cover synchronization behavior and ruleset policy invariants. + +Run locally with: + +```bash +bats tests +``` + +The test suite covers Nextcloud and non-Nextcloud repositories, API error handling, bypass idempotency, repository targeting, ruleset normalization, and policy invariants. + +## ShellCheck + +Shell scripts are analyzed independently with ShellCheck: + +```bash +shellcheck scripts/*.sh +``` + +## actionlint + +GitHub Actions workflow syntax and expressions are validated by the dedicated `actionlint` workflow. + +## zizmor + +GitHub Actions security is audited by the dedicated `zizmor` workflow. Analysis runs without access to the ruleset GitHub App credentials. + +## Dependency updates + +Third-party Actions are pinned to full commit SHAs. Dependabot checks GitHub Actions dependencies weekly and applies a seven-day cooldown before proposing updates. diff --git a/scripts/sync-rulesets.sh b/scripts/sync-rulesets.sh index 06967c1..6ded420 100755 --- a/scripts/sync-rulesets.sh +++ b/scripts/sync-rulesets.sh @@ -1,67 +1,273 @@ #!/usr/bin/env bash set -euo pipefail -ORG="${ORG:?ORG must be set}" -RULESET_FILE="${RULESET_FILE:-.github/rulesets/default-branches.json}" -RULESET_NAME="$(jq -r '.name' "$RULESET_FILE")" -NEXTCLOUD_BOT="${NEXTCLOUD_BOT:-nextcloud-bot}" -NEXTCLOUD_BOT_ID="$(gh api "users/$NEXTCLOUD_BOT" --jq '.id')" - -gh api \ - --paginate \ - '/installation/repositories?per_page=100' \ - --jq '.repositories[] | - select(.owner.login == "'"$ORG"'") | - select(.archived == false) | - select(.visibility == "public") | - .name' | -while read -r repo; do - echo "=== $ORG/$repo ===" +DEFAULT_RULESET_FILE=".github/rulesets/default-branches.json" +NEXTCLOUD_BOT="nextcloud-bot" +NEXTCLOUD_BOT_ID="20296731" +CHECK_ONLY=false +TARGET_REPOSITORY="" +TEMP_FILES=() + +cleanup() { + if [ "${#TEMP_FILES[@]}" -gt 0 ]; then + rm -f "${TEMP_FILES[@]}" + fi +} +trap cleanup EXIT + +usage() { + cat <<'EOF' +Usage: sync-rulesets.sh [--check] [--repo OWNER/REPO] + +Options: + --check Report drift without changing repository rulesets. + --repo OWNER/REPO Sync only one repository instead of all public repositories. + -h, --help Show this help. +EOF +} + +parse_args() { + while [ "$#" -gt 0 ]; do + case "$1" in + --check) + CHECK_ONLY=true + ;; + --repo) + [ "$#" -ge 2 ] || { echo "--repo requires OWNER/REPO" >&2; return 2; } + TARGET_REPOSITORY="$2" + shift + ;; + -h|--help) + usage + exit 0 + ;; + *) + echo "Unknown argument: $1" >&2 + usage >&2 + return 2 + ;; + esac + shift + done +} + +list_repositories() { + if [ -n "$TARGET_REPOSITORY" ]; then + case "$TARGET_REPOSITORY" in + "$ORG"/*) printf '%s\n' "${TARGET_REPOSITORY#*/}" ;; + *) echo "Repository must belong to $ORG: $TARGET_REPOSITORY" >&2; return 2 ;; + esac + return + fi + + gh api \ + --paginate \ + '/installation/repositories?per_page=100' \ + --jq '.repositories[] | + select(.owner.login == "'"$ORG"'") | + select(.archived == false) | + select(.visibility == "public") | + .name' +} + +is_nextcloud_app() { + local repo="$1" + local error_file + error_file="$(mktemp)" + TEMP_FILES+=("$error_file") + + if gh api "repos/$ORG/$repo/contents/appinfo/info.xml" --silent >/dev/null 2>"$error_file"; then + return 0 + fi + + if grep -q 'HTTP 404' "$error_file"; then + return 1 + fi - effective_ruleset_file="$RULESET_FILE" - temporary_ruleset_file="" + echo "Failed to detect whether $ORG/$repo is a Nextcloud app:" >&2 + cat "$error_file" >&2 + return 2 +} - if gh api "repos/$ORG/$repo/contents/appinfo/info.xml" --silent >/dev/null 2>&1; then - echo "Nextcloud app detected; allowing $NEXTCLOUD_BOT to bypass the ruleset" +build_ruleset() { + local repo="$1" + local detection_status=0 - temporary_ruleset_file="$(mktemp)" + if is_nextcloud_app "$repo"; then + echo "Nextcloud app detected; allowing $NEXTCLOUD_BOT to bypass the ruleset" >&2 jq \ --argjson bot_id "$NEXTCLOUD_BOT_ID" \ - '.bypass_actors += [{ - actor_id: $bot_id, - actor_type: "User", - bypass_mode: "always" - }]' \ - "$RULESET_FILE" > "$temporary_ruleset_file" - effective_ruleset_file="$temporary_ruleset_file" - fi - - ruleset_id="$( - gh api "repos/$ORG/$repo/rulesets" \ - --jq ".[] | select(.name == \"$RULESET_NAME\") | .id" \ - 2>/dev/null | - head -n 1 - )" + '.bypass_actors = ( + .bypass_actors + + [{actor_id: $bot_id, actor_type: "User", bypass_mode: "always"}] + | unique_by([.actor_type, .actor_id]) + )' \ + "$RULESET_FILE" + return + else + detection_status=$? + fi + + if [ "$detection_status" -eq 1 ]; then + cat "$RULESET_FILE" + return + fi + + return "$detection_status" +} + +find_ruleset_id() { + local repo="$1" + gh api "repos/$ORG/$repo/rulesets" | + jq -r --arg name "$RULESET_NAME" '.[] | select(.name == $name) | .id' | + head -n 1 +} + +normalize_ruleset() { + jq -S ' + { + name, + target, + enforcement, + bypass_actors: [ + .bypass_actors[] | + { + actor_id: (if .actor_type == "OrganizationAdmin" then null else .actor_id end), + actor_type, + bypass_mode + } + ] | sort_by([.actor_type, .actor_id]), + conditions, + rules: [ + .rules[] | + if .type == "pull_request" then + { + type, + parameters: { + allowed_merge_methods: .parameters.allowed_merge_methods, + dismiss_stale_reviews_on_push: .parameters.dismiss_stale_reviews_on_push, + require_code_owner_review: .parameters.require_code_owner_review, + require_last_push_approval: .parameters.require_last_push_approval, + required_approving_review_count: .parameters.required_approving_review_count, + required_review_thread_resolution: .parameters.required_review_thread_resolution + } + } + else + {type} + end + ] + } + ' +} + +ruleset_has_drift() { + local repo="$1" + local ruleset_id="$2" + local desired_file="$3" + local current desired + + if ! current="$(gh api "repos/$ORG/$repo/rulesets/$ruleset_id" | normalize_ruleset)"; then + return 2 + fi + if ! desired="$(normalize_ruleset < "$desired_file")"; then + return 2 + fi + + [ "$current" != "$desired" ] +} + +sync_repository() { + local repo="$1" + local desired_file ruleset_id drift_status status + + echo "=== $ORG/$repo ===" + + desired_file="$(mktemp)" + TEMP_FILES+=("$desired_file") + if build_ruleset "$repo" > "$desired_file"; then + : + else + status=$? + return "$status" + fi + + if ruleset_id="$(find_ruleset_id "$repo")"; then + : + else + status=$? + echo "Failed to read rulesets for $ORG/$repo" >&2 + return "$status" + fi if [ -z "$ruleset_id" ]; then - echo "Creating ruleset" + if [ "$CHECK_ONLY" = true ]; then + echo "DRIFT: ruleset is missing" + return 1 + fi + echo "Creating ruleset" gh api \ --method POST \ "repos/$ORG/$repo/rulesets" \ -H 'Accept: application/vnd.github+json' \ - --input "$effective_ruleset_file" + --input "$desired_file" + return + fi + + if ruleset_has_drift "$repo" "$ruleset_id" "$desired_file"; then + drift_status=0 else - echo "Updating ruleset $ruleset_id" + drift_status=$? + fi - gh api \ - --method PUT \ - "repos/$ORG/$repo/rulesets/$ruleset_id" \ - -H 'Accept: application/vnd.github+json' \ - --input "$effective_ruleset_file" + if [ "$drift_status" -eq 2 ]; then + echo "Failed to compare ruleset for $ORG/$repo" >&2 + return 2 + fi + + if [ "$drift_status" -eq 1 ]; then + echo "OK: ruleset is up to date" + return fi - if [ -n "$temporary_ruleset_file" ]; then - rm -f "$temporary_ruleset_file" + if [ "$CHECK_ONLY" = true ]; then + echo "DRIFT: ruleset differs from desired configuration" + return 1 fi -done + + echo "Updating ruleset $ruleset_id" + gh api \ + --method PUT \ + "repos/$ORG/$repo/rulesets/$ruleset_id" \ + -H 'Accept: application/vnd.github+json' \ + --input "$desired_file" +} + +main() { + ORG="${ORG:?ORG must be set}" + RULESET_FILE="${RULESET_FILE:-$DEFAULT_RULESET_FILE}" + RULESET_NAME="$(jq -r '.name' "$RULESET_FILE")" + + parse_args "$@" + + local failed=0 repo repositories status + if repositories="$(list_repositories)"; then + : + else + status=$? + return "$status" + fi + + while read -r repo; do + [ -n "$repo" ] || continue + if ! sync_repository "$repo"; then + failed=1 + fi + done <<< "$repositories" + + return "$failed" +} + +if [[ "${BASH_SOURCE[0]}" == "$0" ]]; then + main "$@" +fi diff --git a/tests/ruleset-policy.bats b/tests/ruleset-policy.bats new file mode 100644 index 0000000..02e7609 --- /dev/null +++ b/tests/ruleset-policy.bats @@ -0,0 +1,52 @@ +#!/usr/bin/env bats + +setup() { + REPO_ROOT="$(cd "$BATS_TEST_DIRNAME/.." && pwd)" + RULESET_FILE="$REPO_ROOT/.github/rulesets/default-branches.json" +} + +@test "ruleset JSON is valid" { + run jq -e . "$RULESET_FILE" + [ "$status" -eq 0 ] +} + +@test "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" + + [ "$status" -eq 0 ] +} + +@test "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" + + [ "$status" -eq 0 ] +} + +@test "pull requests require 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" + + [ "$status" -eq 0 ] +} + +@test "organization admins can bypass only through pull requests" { + run jq -e ' + [.bypass_actors[] | + select(.actor_type == "OrganizationAdmin" and .bypass_mode == "pull_request") + ] | length == 1 + ' "$RULESET_FILE" + + [ "$status" -eq 0 ] +} diff --git a/tests/sync-rulesets.bats b/tests/sync-rulesets.bats new file mode 100644 index 0000000..0041c81 --- /dev/null +++ b/tests/sync-rulesets.bats @@ -0,0 +1,108 @@ +#!/usr/bin/env bats + +setup() { + REPO_ROOT="$(cd "$BATS_TEST_DIRNAME/.." && pwd)" + # shellcheck source=../scripts/sync-rulesets.sh + source "$REPO_ROOT/scripts/sync-rulesets.sh" + + ORG="LibreSign" + RULESET_FILE="$REPO_ROOT/.github/rulesets/default-branches.json" + RULESET_NAME="$(jq -r '.name' "$RULESET_FILE")" + CHECK_ONLY=false + TARGET_REPOSITORY="" + TEMP_FILES=() +} + +@test "Nextcloud apps receive the pinned nextcloud-bot bypass" { + gh() { + return 0 + } + + result="$(build_ruleset libresign 2>/dev/null)" + + [ "$(jq '[.bypass_actors[] | select(.actor_type == "User" and .actor_id == 20296731 and .bypass_mode == "always")] | length' <<< "$result")" -eq 1 ] +} + +@test "non-Nextcloud repositories do not receive the bot bypass" { + gh() { + echo "gh: Not Found (HTTP 404)" >&2 + return 1 + } + + result="$(build_ruleset docs)" + + [ "$(jq '[.bypass_actors[] | select(.actor_type == "User" and .actor_id == 20296731)] | length' <<< "$result")" -eq 0 ] +} + +@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 + + [ "$status" -eq 2 ] + [[ "$output" == *"Failed to detect whether LibreSign/libresign is a Nextcloud app"* ]] +} + +@test "server errors while detecting a Nextcloud app fail closed" { + gh() { + echo "gh: Server Error (HTTP 500)" >&2 + return 1 + } + + run build_ruleset libresign + + [ "$status" -eq 2 ] +} + +@test "adding the bot bypass is idempotent" { + fixture="$(mktemp)" + TEMP_FILES+=("$fixture") + jq '.bypass_actors += [{actor_id: 20296731, actor_type: "User", bypass_mode: "always"}]' \ + "$RULESET_FILE" > "$fixture" + RULESET_FILE="$fixture" + + gh() { + return 0 + } + + result="$(build_ruleset libresign 2>/dev/null)" + + [ "$(jq '[.bypass_actors[] | select(.actor_type == "User" and .actor_id == 20296731)] | length' <<< "$result")" -eq 1 ] +} + +@test "repository targeting only accepts repositories from the selected organization" { + TARGET_REPOSITORY="LibreCodeCoop/profile_fields" + + run list_repositories + + [ "$status" -eq 2 ] + [[ "$output" == *"Repository must belong to LibreSign"* ]] +} + +@test "repository targeting returns only the selected repository name" { + TARGET_REPOSITORY="LibreSign/libresign" + + run list_repositories + + [ "$status" -eq 0 ] + [ "$output" = "libresign" ] +} + +@test "normalization ignores GitHub API defaults not managed by this policy" { + desired="$(normalize_ruleset < "$RULESET_FILE")" + current="$( + jq ' + .bypass_actors[0].actor_id = null | + (.rules[] | select(.type == "pull_request") | .parameters) += { + required_reviewers: [], + dismissal_restriction: {enabled: false, allowed_actors: []}, + require_extra_approval_for_unattributed_changes: true + } + ' "$RULESET_FILE" | normalize_ruleset + )" + + [ "$current" = "$desired" ] +}