From 8a279029061baa047aa7e92f3999755962998e5d Mon Sep 17 00:00:00 2001 From: oraz Date: Tue, 31 Mar 2026 11:58:54 +0300 Subject: [PATCH 1/5] Add script to manage signed commit requirements via GitHub API Prow branchprotector does not support required_signatures, so this script fills the gap by enabling/disabling it per branch using a YAML config file (signed-commits.yaml). RHWA-788 Assisted-by: Claude claude-opus-4-6 --- scripts/require-signed-commits.sh | 103 ++++++++++++++++++++++++++++++ scripts/signed-commits.yaml | 66 +++++++++++++++++++ 2 files changed, 169 insertions(+) create mode 100755 scripts/require-signed-commits.sh create mode 100644 scripts/signed-commits.yaml diff --git a/scripts/require-signed-commits.sh b/scripts/require-signed-commits.sh new file mode 100755 index 0000000..8f6964d --- /dev/null +++ b/scripts/require-signed-commits.sh @@ -0,0 +1,103 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +CONFIG="${SCRIPT_DIR}/signed-commits.yaml" + +DRY_RUN=false +ACTION="enable" + +log() { echo "==> $*"; } +info() { echo " $*"; } +warn() { echo "WARNING: $*" >&2; } +die() { echo "ERROR: $*" >&2; exit 1; } + +while [[ $# -gt 0 ]]; do + case "$1" in + --dry-run) DRY_RUN=true; shift ;; + --disable) ACTION="disable"; shift ;; + --config) + if [[ -z "${2:-}" ]]; then + die "--config requires a path argument" + fi + CONFIG="$2"; shift 2 ;; + --help|-h) + echo "Usage: $0 [--dry-run] [--disable] [--config PATH]" + echo "" + echo "Enable or disable required signed commits on branches defined in a YAML config." + echo "Reads repos and branches from signed-commits.yaml (or --config PATH)." + echo "" + echo "Options:" + echo " --dry-run Show what would be done without making changes" + echo " --disable Remove the requirement instead of adding it" + echo " --config PATH Path to YAML config (default: signed-commits.yaml next to this script)" + exit 0 + ;; + *) die "Unknown option: $1" ;; + esac +done + +[[ -f "$CONFIG" ]] || die "Config file not found: $CONFIG" +command -v yq &>/dev/null || die "yq is required. Install from https://github.com/mikefarah/yq" +gh auth status &>/dev/null || die "Not authenticated with gh CLI. Run 'gh auth login' first." + +ORG=$(yq '.org' "$CONFIG") +REPOS=$(yq '.repos | keys | .[]' "$CONFIG") + +changed=0 +skipped=0 +failed=0 + +for repo in $REPOS; do + log "$repo" + branches=$(yq ".repos.\"${repo}\".branches[]" "$CONFIG") + + for branch in $branches; do + api_response=$(gh api "repos/${ORG}/${repo}/branches/${branch}/protection/required_signatures" 2>&1) && \ + current=$(echo "$api_response" | yq -p json '.enabled') || \ + current="no-protection" + + if [[ "$ACTION" == "disable" ]]; then + if [[ "$current" == "false" || "$current" == "no-protection" ]]; then + info "$branch — already disabled" + ((skipped++)) || true + continue + fi + if [[ "$DRY_RUN" == "true" ]]; then + info "[dry-run] $branch — would DISABLE signed commits" + continue + fi + if gh api "repos/${ORG}/${repo}/branches/${branch}/protection/required_signatures" --method DELETE --silent 2>/dev/null; then + info "$branch — disabled" + ((changed++)) || true + else + warn "$repo/$branch — failed to disable" + ((failed++)) || true + fi + else + if [[ "$current" == "true" ]]; then + info "$branch — already enabled" + ((skipped++)) || true + continue + fi + if [[ "$current" == "no-protection" ]]; then + warn "$branch — no branch protection rule yet (waiting for branchprotector?)" + ((skipped++)) || true + continue + fi + if [[ "$DRY_RUN" == "true" ]]; then + info "[dry-run] $branch — would ENABLE signed commits" + continue + fi + if gh api "repos/${ORG}/${repo}/branches/${branch}/protection/required_signatures" --method POST --silent 2>/dev/null; then + info "$branch — enabled" + ((changed++)) || true + else + warn "$repo/$branch — failed to enable (need admin access?)" + ((failed++)) || true + fi + fi + done +done + +log "Done: changed=${changed} skipped=${skipped} failed=${failed}" diff --git a/scripts/signed-commits.yaml b/scripts/signed-commits.yaml new file mode 100644 index 0000000..455611c --- /dev/null +++ b/scripts/signed-commits.yaml @@ -0,0 +1,66 @@ +org: medik8s +repos: + node-healthcheck-operator: + branches: + - main + - release-0.4 + - release-0.6 + - release-0.8 + - release-0.9 + - release-0.10 + - release-0.11 + self-node-remediation: + branches: + - main + - release-0.5 + - release-0.7 + - release-0.9 + - release-0.10 + - release-0.11 + - release-0.12 + fence-agents-remediation: + branches: + - main + - release-0.2 + - release-0.4 + - release-0.5 + - release-0.6 + - release-0.7 + machine-deletion-remediation: + branches: + - main + - release-0.2 + - release-0.3 + - release-0.4 + - release-0.5 + - release-0.6 + node-maintenance-operator: + branches: + - main + - release-0.14 + - release-0.16 + - release-0.17 + - release-0.18 + - release-0.19 + - release-0.20 + storage-based-remediation: + branches: + - main + node-remediation-console: + branches: + - main + - release-0.4 + - release-0.6 + - release-0.8 + - release-0.9 + - release-0.10 + - release-0.11 + must-gather: + branches: + - main + - release-0.4 + - release-0.6 + - release-0.8 + - release-0.9 + - release-0.10 + - release-0.11 From d989e71c57dc4c0be9a1061e9f8a4d5cd22e766c Mon Sep 17 00:00:00 2001 From: oraz Date: Wed, 24 Jun 2026 15:00:50 +0300 Subject: [PATCH 2/5] Improve error handling in require-signed-commits.sh Distinguish API errors from missing branch protection rules instead of treating all failures as "no-protection". Capture and include actual error messages in warnings for enable/disable operations. Add a comment in signed-commits.yaml documenting that the branch list corresponds to supported OCP operator versions. Co-Authored-By: Claude Opus 4.6 --- scripts/require-signed-commits.sh | 20 ++++++++++++++------ scripts/signed-commits.yaml | 2 ++ 2 files changed, 16 insertions(+), 6 deletions(-) diff --git a/scripts/require-signed-commits.sh b/scripts/require-signed-commits.sh index 8f6964d..d2b669d 100755 --- a/scripts/require-signed-commits.sh +++ b/scripts/require-signed-commits.sh @@ -53,9 +53,17 @@ for repo in $REPOS; do branches=$(yq ".repos.\"${repo}\".branches[]" "$CONFIG") for branch in $branches; do - api_response=$(gh api "repos/${ORG}/${repo}/branches/${branch}/protection/required_signatures" 2>&1) && \ - current=$(echo "$api_response" | yq -p json '.enabled') || \ + api_response=$(gh api "repos/${ORG}/${repo}/branches/${branch}/protection/required_signatures" 2>&1) + api_exit=$? + if [[ $api_exit -eq 0 ]]; then + current=$(echo "$api_response" | yq -p json '.enabled') + elif echo "$api_response" | grep -q "Branch not protected\|Not Found"; then current="no-protection" + else + warn "$branch — API error: $api_response" + ((failed++)) || true + continue + fi if [[ "$ACTION" == "disable" ]]; then if [[ "$current" == "false" || "$current" == "no-protection" ]]; then @@ -67,11 +75,11 @@ for repo in $REPOS; do info "[dry-run] $branch — would DISABLE signed commits" continue fi - if gh api "repos/${ORG}/${repo}/branches/${branch}/protection/required_signatures" --method DELETE --silent 2>/dev/null; then + if api_err=$(gh api "repos/${ORG}/${repo}/branches/${branch}/protection/required_signatures" --method DELETE 2>&1); then info "$branch — disabled" ((changed++)) || true else - warn "$repo/$branch — failed to disable" + warn "$repo/$branch — failed to disable: $api_err" ((failed++)) || true fi else @@ -89,11 +97,11 @@ for repo in $REPOS; do info "[dry-run] $branch — would ENABLE signed commits" continue fi - if gh api "repos/${ORG}/${repo}/branches/${branch}/protection/required_signatures" --method POST --silent 2>/dev/null; then + if api_err=$(gh api "repos/${ORG}/${repo}/branches/${branch}/protection/required_signatures" --method POST 2>&1); then info "$branch — enabled" ((changed++)) || true else - warn "$repo/$branch — failed to enable (need admin access?)" + warn "$repo/$branch — failed to enable: $api_err" ((failed++)) || true fi fi diff --git a/scripts/signed-commits.yaml b/scripts/signed-commits.yaml index 455611c..1a3d89e 100644 --- a/scripts/signed-commits.yaml +++ b/scripts/signed-commits.yaml @@ -1,3 +1,5 @@ +# Branches correspond to supported OCP operator versions per: +# https://access.redhat.com/support/policy/updates/openshift_operators#platform-aligned org: medik8s repos: node-healthcheck-operator: From b714ccc3ffbe60c3edf1bd359b6f9a0caeafb912 Mon Sep 17 00:00:00 2001 From: oraz Date: Wed, 24 Jun 2026 17:13:03 +0300 Subject: [PATCH 3/5] Fix set -e interaction with exit code capture MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The `api_response=$(gh api ...) + api_exit=$?` pattern silently aborts under `set -e` when gh returns non-zero — the assignment fails before `api_exit=$?` executes. Use `|| api_exit=$?` to capture the exit code inline. Co-Authored-By: Claude Opus 4.6 --- scripts/require-signed-commits.sh | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/scripts/require-signed-commits.sh b/scripts/require-signed-commits.sh index d2b669d..aada1cc 100755 --- a/scripts/require-signed-commits.sh +++ b/scripts/require-signed-commits.sh @@ -53,8 +53,8 @@ for repo in $REPOS; do branches=$(yq ".repos.\"${repo}\".branches[]" "$CONFIG") for branch in $branches; do - api_response=$(gh api "repos/${ORG}/${repo}/branches/${branch}/protection/required_signatures" 2>&1) - api_exit=$? + api_exit=0 + api_response=$(gh api "repos/${ORG}/${repo}/branches/${branch}/protection/required_signatures" 2>&1) || api_exit=$? if [[ $api_exit -eq 0 ]]; then current=$(echo "$api_response" | yq -p json '.enabled') elif echo "$api_response" | grep -q "Branch not protected\|Not Found"; then From 3d7e2acd7fd069c7328d04010292a8a9b5b28197 Mon Sep 17 00:00:00 2001 From: oraz Date: Wed, 24 Jun 2026 17:22:49 +0300 Subject: [PATCH 4/5] Harden script against injection, accidental disable, and silent failures - Require --confirm with --disable to prevent accidental protection removal - Replace unquoted for-loops with while-read to prevent word splitting - Validate org/repo/branch names against [a-zA-Z0-9._-] to block path traversal - Detect wrong yq variant (kislyuk vs mikefarah) at startup - Add timestamps to info/warn output for audit trail - Exit non-zero when any operations fail Co-Authored-By: Claude Opus 4.6 --- scripts/require-signed-commits.sh | 33 +++++++++++++++++++++---------- 1 file changed, 23 insertions(+), 10 deletions(-) diff --git a/scripts/require-signed-commits.sh b/scripts/require-signed-commits.sh index aada1cc..d88f583 100755 --- a/scripts/require-signed-commits.sh +++ b/scripts/require-signed-commits.sh @@ -6,23 +6,27 @@ CONFIG="${SCRIPT_DIR}/signed-commits.yaml" DRY_RUN=false ACTION="enable" +CONFIRM=false log() { echo "==> $*"; } -info() { echo " $*"; } -warn() { echo "WARNING: $*" >&2; } +info() { echo " $(date -u +%H:%M:%SZ) $*"; } +warn() { echo "WARNING: $(date -u +%H:%M:%SZ) $*" >&2; } die() { echo "ERROR: $*" >&2; exit 1; } +valid_name() { [[ "$1" =~ ^[a-zA-Z0-9._-]+$ ]]; } + while [[ $# -gt 0 ]]; do case "$1" in --dry-run) DRY_RUN=true; shift ;; --disable) ACTION="disable"; shift ;; + --confirm) CONFIRM=true; shift ;; --config) if [[ -z "${2:-}" ]]; then die "--config requires a path argument" fi CONFIG="$2"; shift 2 ;; --help|-h) - echo "Usage: $0 [--dry-run] [--disable] [--config PATH]" + echo "Usage: $0 [--dry-run] [--disable --confirm] [--config PATH]" echo "" echo "Enable or disable required signed commits on branches defined in a YAML config." echo "Reads repos and branches from signed-commits.yaml (or --config PATH)." @@ -30,6 +34,7 @@ while [[ $# -gt 0 ]]; do echo "Options:" echo " --dry-run Show what would be done without making changes" echo " --disable Remove the requirement instead of adding it" + echo " --confirm Required with --disable to prevent accidental removal" echo " --config PATH Path to YAML config (default: signed-commits.yaml next to this script)" exit 0 ;; @@ -37,22 +42,29 @@ while [[ $# -gt 0 ]]; do esac done +if [[ "$ACTION" == "disable" && "$DRY_RUN" != "true" && "$CONFIRM" != "true" ]]; then + die "--disable requires --confirm (or use --dry-run to preview)" +fi + [[ -f "$CONFIG" ]] || die "Config file not found: $CONFIG" command -v yq &>/dev/null || die "yq is required. Install from https://github.com/mikefarah/yq" +yq --version 2>&1 | grep -q "mikefarah" || die "Wrong yq variant detected. Need mikefarah/yq, not kislyuk/yq." gh auth status &>/dev/null || die "Not authenticated with gh CLI. Run 'gh auth login' first." -ORG=$(yq '.org' "$CONFIG") -REPOS=$(yq '.repos | keys | .[]' "$CONFIG") +ORG=$(yq '.org' "$CONFIG") || die "Failed to parse org from $CONFIG" +valid_name "$ORG" || die "Invalid org name: $ORG" changed=0 skipped=0 failed=0 -for repo in $REPOS; do +while IFS= read -r repo; do + valid_name "$repo" || { warn "Skipping invalid repo name: $repo"; ((failed++)) || true; continue; } log "$repo" - branches=$(yq ".repos.\"${repo}\".branches[]" "$CONFIG") - for branch in $branches; do + while IFS= read -r branch; do + valid_name "$branch" || { warn "Skipping invalid branch name: $branch"; ((failed++)) || true; continue; } + api_exit=0 api_response=$(gh api "repos/${ORG}/${repo}/branches/${branch}/protection/required_signatures" 2>&1) || api_exit=$? if [[ $api_exit -eq 0 ]]; then @@ -105,7 +117,8 @@ for repo in $REPOS; do ((failed++)) || true fi fi - done -done + done < <(yq ".repos.\"${repo}\".branches[]" "$CONFIG") +done < <(yq '.repos | keys | .[]' "$CONFIG") log "Done: changed=${changed} skipped=${skipped} failed=${failed}" +[[ "$failed" -eq 0 ]] || exit 1 From 390abe4abb2155f3471563a511cbadb01e1d0cf8 Mon Sep 17 00:00:00 2001 From: oraz Date: Thu, 25 Jun 2026 10:51:46 +0300 Subject: [PATCH 5/5] Replace static YAML config with dynamic repo/branch discovery Drop signed-commits.yaml and its yq dependency. The script now discovers repos via the GitHub API (non-archived, non-fork) and filters branches with a regex pattern (default: main|release-.+). New flags: --org, --repo (repeatable), --branch (repeatable). Removed: --config. Co-Authored-By: Claude Opus 4.6 --- scripts/require-signed-commits.sh | 111 ++++++++++++++++++++++-------- scripts/signed-commits.yaml | 68 ------------------ 2 files changed, 83 insertions(+), 96 deletions(-) delete mode 100644 scripts/signed-commits.yaml diff --git a/scripts/require-signed-commits.sh b/scripts/require-signed-commits.sh index d88f583..60186ac 100755 --- a/scripts/require-signed-commits.sh +++ b/scripts/require-signed-commits.sh @@ -1,12 +1,13 @@ #!/usr/bin/env bash set -euo pipefail -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -CONFIG="${SCRIPT_DIR}/signed-commits.yaml" - +ORG="medik8s" DRY_RUN=false ACTION="enable" CONFIRM=false +REPOS=() +BRANCHES=() +DEFAULT_BRANCH_PATTERN='main|release-.+' log() { echo "==> $*"; } info() { echo " $(date -u +%H:%M:%SZ) $*"; } @@ -20,22 +21,39 @@ while [[ $# -gt 0 ]]; do --dry-run) DRY_RUN=true; shift ;; --disable) ACTION="disable"; shift ;; --confirm) CONFIRM=true; shift ;; - --config) - if [[ -z "${2:-}" ]]; then - die "--config requires a path argument" - fi - CONFIG="$2"; shift 2 ;; + --org) + [[ -n "${2:-}" ]] || die "--org requires a value" + ORG="$2"; shift 2 ;; + --repo) + [[ -n "${2:-}" ]] || die "--repo requires a value" + REPOS+=("$2"); shift 2 ;; + --branch) + [[ -n "${2:-}" ]] || die "--branch requires a value" + BRANCHES+=("$2"); shift 2 ;; --help|-h) - echo "Usage: $0 [--dry-run] [--disable --confirm] [--config PATH]" - echo "" - echo "Enable or disable required signed commits on branches defined in a YAML config." - echo "Reads repos and branches from signed-commits.yaml (or --config PATH)." - echo "" - echo "Options:" - echo " --dry-run Show what would be done without making changes" - echo " --disable Remove the requirement instead of adding it" - echo " --confirm Required with --disable to prevent accidental removal" - echo " --config PATH Path to YAML config (default: signed-commits.yaml next to this script)" + cat <<'EOF' +Usage: require-signed-commits.sh [OPTIONS] + +Enable or disable required signed commits on protected branches. + +By default, discovers all non-archived, non-fork repos in the org and +targets branches matching: main, release-* + +Options: + --dry-run Show what would be done without making changes + --disable Remove the requirement instead of adding it + --confirm Required with --disable to prevent accidental removal + --org ORG GitHub organization (default: medik8s) + --repo REPO Target specific repo(s) — repeatable + --branch PATTERN Override branch filter — repeatable, regex + (default: main and release-.+) + +Examples: + require-signed-commits.sh --dry-run + require-signed-commits.sh --repo self-node-remediation --dry-run + require-signed-commits.sh --repo nhc --repo snr --branch main --dry-run + require-signed-commits.sh --disable --confirm +EOF exit 0 ;; *) die "Unknown option: $1" ;; @@ -46,29 +64,66 @@ if [[ "$ACTION" == "disable" && "$DRY_RUN" != "true" && "$CONFIRM" != "true" ]]; die "--disable requires --confirm (or use --dry-run to preview)" fi -[[ -f "$CONFIG" ]] || die "Config file not found: $CONFIG" -command -v yq &>/dev/null || die "yq is required. Install from https://github.com/mikefarah/yq" -yq --version 2>&1 | grep -q "mikefarah" || die "Wrong yq variant detected. Need mikefarah/yq, not kislyuk/yq." gh auth status &>/dev/null || die "Not authenticated with gh CLI. Run 'gh auth login' first." - -ORG=$(yq '.org' "$CONFIG") || die "Failed to parse org from $CONFIG" valid_name "$ORG" || die "Invalid org name: $ORG" +# Build branch pattern from --branch flags or default +if [[ ${#BRANCHES[@]} -gt 0 ]]; then + branch_pattern=$(IFS='|'; echo "${BRANCHES[*]}") +else + branch_pattern="$DEFAULT_BRANCH_PATTERN" +fi + +# Validate regex before use (grep exit 1 = no match, exit 2 = bad regex) +grep_exit=0 +grep -E "^(${branch_pattern})$" /dev/null >/dev/null 2>&1 || grep_exit=$? +[[ $grep_exit -le 1 ]] || die "Invalid branch regex: ${branch_pattern}" + +# Discover repos: --repo flags or all non-archived, non-fork repos in the org +if [[ ${#REPOS[@]} -eq 0 ]]; then + log "Discovering repos in ${ORG} (non-archived, non-fork)..." + while IFS= read -r r; do + [[ -n "$r" ]] && REPOS+=("$r") + done < <(gh api "orgs/${ORG}/repos" --paginate \ + --jq '.[] | select(.archived == false and .fork == false) | .name') + [[ ${#REPOS[@]} -gt 0 ]] || die "No repos found in org ${ORG}" + log "Found ${#REPOS[@]} repos" +fi + changed=0 skipped=0 failed=0 -while IFS= read -r repo; do +for repo in "${REPOS[@]}"; do valid_name "$repo" || { warn "Skipping invalid repo name: $repo"; ((failed++)) || true; continue; } log "$repo" - while IFS= read -r branch; do + branches_exit=0 + branches_response=$(gh api "repos/${ORG}/${repo}/branches" --paginate --jq '.[].name' 2>&1) || branches_exit=$? + if [[ $branches_exit -ne 0 ]]; then + warn "Failed to list branches for ${ORG}/${repo}: ${branches_response}" + ((failed++)) || true + continue + fi + + matched_branches=() + while IFS= read -r b; do + [[ -n "$b" ]] && matched_branches+=("$b") + done < <(printf '%s\n' "$branches_response" \ + | grep -E "^(${branch_pattern})$" || true) + + if [[ ${#matched_branches[@]} -eq 0 ]]; then + info "(no branches matching: ${branch_pattern})" + continue + fi + + for branch in "${matched_branches[@]}"; do valid_name "$branch" || { warn "Skipping invalid branch name: $branch"; ((failed++)) || true; continue; } api_exit=0 api_response=$(gh api "repos/${ORG}/${repo}/branches/${branch}/protection/required_signatures" 2>&1) || api_exit=$? if [[ $api_exit -eq 0 ]]; then - current=$(echo "$api_response" | yq -p json '.enabled') + current=$(echo "$api_response" | grep -o '"enabled":\s*[a-z]*' | grep -o 'true\|false' || echo "unknown") elif echo "$api_response" | grep -q "Branch not protected\|Not Found"; then current="no-protection" else @@ -117,8 +172,8 @@ while IFS= read -r repo; do ((failed++)) || true fi fi - done < <(yq ".repos.\"${repo}\".branches[]" "$CONFIG") -done < <(yq '.repos | keys | .[]' "$CONFIG") + done +done log "Done: changed=${changed} skipped=${skipped} failed=${failed}" [[ "$failed" -eq 0 ]] || exit 1 diff --git a/scripts/signed-commits.yaml b/scripts/signed-commits.yaml deleted file mode 100644 index 1a3d89e..0000000 --- a/scripts/signed-commits.yaml +++ /dev/null @@ -1,68 +0,0 @@ -# Branches correspond to supported OCP operator versions per: -# https://access.redhat.com/support/policy/updates/openshift_operators#platform-aligned -org: medik8s -repos: - node-healthcheck-operator: - branches: - - main - - release-0.4 - - release-0.6 - - release-0.8 - - release-0.9 - - release-0.10 - - release-0.11 - self-node-remediation: - branches: - - main - - release-0.5 - - release-0.7 - - release-0.9 - - release-0.10 - - release-0.11 - - release-0.12 - fence-agents-remediation: - branches: - - main - - release-0.2 - - release-0.4 - - release-0.5 - - release-0.6 - - release-0.7 - machine-deletion-remediation: - branches: - - main - - release-0.2 - - release-0.3 - - release-0.4 - - release-0.5 - - release-0.6 - node-maintenance-operator: - branches: - - main - - release-0.14 - - release-0.16 - - release-0.17 - - release-0.18 - - release-0.19 - - release-0.20 - storage-based-remediation: - branches: - - main - node-remediation-console: - branches: - - main - - release-0.4 - - release-0.6 - - release-0.8 - - release-0.9 - - release-0.10 - - release-0.11 - must-gather: - branches: - - main - - release-0.4 - - release-0.6 - - release-0.8 - - release-0.9 - - release-0.10 - - release-0.11