|
| 1 | +#!/usr/bin/env bash |
| 2 | +# SPDX-License-Identifier: PMPL-1.0-or-later |
| 3 | +# Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> |
| 4 | +# |
| 5 | +# fix-dependabot-coverage.sh |
| 6 | +# |
| 7 | +# Scans a repository for package manager files (Cargo.toml, package.json, |
| 8 | +# mix.exs, etc.) in subdirectories and ensures .github/dependabot.yml has |
| 9 | +# matching directory entries. Also removes semver-patch ignore rules that |
| 10 | +# block security patch updates. |
| 11 | +# |
| 12 | +# Usage: |
| 13 | +# fix-dependabot-coverage.sh [repo-path] |
| 14 | +# |
| 15 | +# If repo-path is omitted, the current directory is used. |
| 16 | + |
| 17 | +set -euo pipefail |
| 18 | + |
| 19 | +# --- Configuration --- |
| 20 | + |
| 21 | +REPO_ROOT="${1:-.}" |
| 22 | +DEPENDABOT_FILE="${REPO_ROOT}/.github/dependabot.yml" |
| 23 | + |
| 24 | +# Map of filename -> dependabot ecosystem name |
| 25 | +declare -A ECOSYSTEM_MAP=( |
| 26 | + ["Cargo.toml"]="cargo" |
| 27 | + ["package.json"]="npm" |
| 28 | + ["mix.exs"]="mix" |
| 29 | + ["go.mod"]="gomod" |
| 30 | + ["Gemfile"]="bundler" |
| 31 | + ["requirements.txt"]="pip" |
| 32 | + ["setup.py"]="pip" |
| 33 | + ["pyproject.toml"]="pip" |
| 34 | + ["build.gradle"]="gradle" |
| 35 | + ["build.gradle.kts"]="gradle" |
| 36 | + ["pom.xml"]="maven" |
| 37 | + ["composer.json"]="composer" |
| 38 | + ["flake.nix"]="nix" |
| 39 | + ["pubspec.yaml"]="pub" |
| 40 | +) |
| 41 | + |
| 42 | +# --- Validation --- |
| 43 | + |
| 44 | +if [[ ! -d "${REPO_ROOT}" ]]; then |
| 45 | + echo "ERROR: Repository path '${REPO_ROOT}' does not exist." >&2 |
| 46 | + exit 1 |
| 47 | +fi |
| 48 | + |
| 49 | +if [[ ! -f "${DEPENDABOT_FILE}" ]]; then |
| 50 | + echo "ERROR: ${DEPENDABOT_FILE} not found. Create it first." >&2 |
| 51 | + exit 1 |
| 52 | +fi |
| 53 | + |
| 54 | +# --- Functions --- |
| 55 | + |
| 56 | +# Check whether a given ecosystem+directory pair already exists in dependabot.yml. |
| 57 | +# Uses a simple text-based check: looks for the ecosystem line followed by |
| 58 | +# the directory line within a few lines of each other. |
| 59 | +entry_exists() { |
| 60 | + local ecosystem="$1" |
| 61 | + local directory="$2" |
| 62 | + |
| 63 | + # Normalise directory for matching: dependabot uses "/" for root, |
| 64 | + # "/subdir" for subdirectories. |
| 65 | + local dir_pattern |
| 66 | + if [[ "${directory}" == "/" ]]; then |
| 67 | + dir_pattern='directory: "/"' |
| 68 | + else |
| 69 | + dir_pattern="directory: \"${directory}\"" |
| 70 | + fi |
| 71 | + |
| 72 | + # Search for the ecosystem+directory pair within the file. |
| 73 | + # We look for both on adjacent lines (within 5 lines of each other). |
| 74 | + if awk -v eco="\"${ecosystem}\"" -v dir="${dir_pattern}" ' |
| 75 | + /package-ecosystem:/ && $0 ~ eco { found_eco=NR } |
| 76 | + found_eco && NR <= found_eco+5 && $0 ~ dir { exit 0 } |
| 77 | + END { exit 1 } |
| 78 | + ' "${DEPENDABOT_FILE}"; then |
| 79 | + return 0 |
| 80 | + else |
| 81 | + return 1 |
| 82 | + fi |
| 83 | +} |
| 84 | + |
| 85 | +# Append a new ecosystem+directory entry to dependabot.yml. |
| 86 | +append_entry() { |
| 87 | + local ecosystem="$1" |
| 88 | + local directory="$2" |
| 89 | + |
| 90 | + cat >> "${DEPENDABOT_FILE}" <<EOF |
| 91 | +
|
| 92 | + - package-ecosystem: "${ecosystem}" |
| 93 | + directory: "${directory}" |
| 94 | + schedule: |
| 95 | + interval: "daily" |
| 96 | +EOF |
| 97 | + echo " ADDED: ${ecosystem} @ ${directory}" |
| 98 | +} |
| 99 | + |
| 100 | +# Remove semver-patch ignore rules from dependabot.yml. |
| 101 | +# Matches the block: |
| 102 | +# ignore: |
| 103 | +# - dependency-name: "*" |
| 104 | +# update-types: ["version-update:semver-patch"] |
| 105 | +remove_semver_patch_ignores() { |
| 106 | + local tmp_file |
| 107 | + tmp_file="$(mktemp)" |
| 108 | + |
| 109 | + # Use awk to strip ignore blocks that contain semver-patch. |
| 110 | + # This handles the 3-line ignore block pattern. |
| 111 | + awk ' |
| 112 | + /^[[:space:]]*ignore:/ { |
| 113 | + # Buffer lines to check if this is a semver-patch ignore block |
| 114 | + buf[0] = $0; n = 1 |
| 115 | + while ((getline line) > 0) { |
| 116 | + buf[n++] = line |
| 117 | + # If we see semver-patch, this entire ignore block should be removed |
| 118 | + if (line ~ /version-update:semver-patch/) { |
| 119 | + # Skip this entire block (do not print buffered lines) |
| 120 | + removed = 1 |
| 121 | + break |
| 122 | + } |
| 123 | + # If we hit a non-indented line or a new key at the same level, |
| 124 | + # this is not a simple semver-patch block - print and move on |
| 125 | + if (line !~ /^[[:space:]]+(- dependency-name:|update-types:)/) { |
| 126 | + for (i = 0; i < n; i++) print buf[i] |
| 127 | + removed = 0 |
| 128 | + break |
| 129 | + } |
| 130 | + } |
| 131 | + if (!removed && n > 0) { |
| 132 | + # End of file reached while buffering - print everything |
| 133 | + for (i = 0; i < n; i++) print buf[i] |
| 134 | + } |
| 135 | + next |
| 136 | + } |
| 137 | + { print } |
| 138 | + ' "${DEPENDABOT_FILE}" > "${tmp_file}" |
| 139 | + |
| 140 | + if ! diff -q "${DEPENDABOT_FILE}" "${tmp_file}" > /dev/null 2>&1; then |
| 141 | + mv "${tmp_file}" "${DEPENDABOT_FILE}" |
| 142 | + echo " REMOVED: semver-patch ignore rule(s)" |
| 143 | + else |
| 144 | + rm -f "${tmp_file}" |
| 145 | + fi |
| 146 | +} |
| 147 | + |
| 148 | +# --- Main --- |
| 149 | + |
| 150 | +echo "=== fix-dependabot-coverage ===" |
| 151 | +echo "Repository: $(cd "${REPO_ROOT}" && pwd)" |
| 152 | +echo "" |
| 153 | + |
| 154 | +# Step 1: Remove semver-patch ignore rules |
| 155 | +echo "--- Checking for semver-patch ignore rules ---" |
| 156 | +remove_semver_patch_ignores |
| 157 | + |
| 158 | +# Step 2: Find all package manager files and ensure coverage |
| 159 | +echo "" |
| 160 | +echo "--- Scanning for package manager files ---" |
| 161 | + |
| 162 | +changes_made=0 |
| 163 | + |
| 164 | +for pkg_file in "${!ECOSYSTEM_MAP[@]}"; do |
| 165 | + ecosystem="${ECOSYSTEM_MAP[${pkg_file}]}" |
| 166 | + |
| 167 | + # Find all instances of this package file in the repo, |
| 168 | + # excluding common non-project directories. |
| 169 | + while IFS= read -r found_path; do |
| 170 | + # Determine the directory relative to repo root |
| 171 | + dir="$(dirname "${found_path}")" |
| 172 | + rel_dir="${dir#"${REPO_ROOT}"}" |
| 173 | + |
| 174 | + # Skip common non-project directories |
| 175 | + if [[ "${rel_dir}" == *"/node_modules/"* ]] || \ |
| 176 | + [[ "${rel_dir}" == *"/target/"* ]] || \ |
| 177 | + [[ "${rel_dir}" == *"/vendor/"* ]] || \ |
| 178 | + [[ "${rel_dir}" == *"/.git/"* ]] || \ |
| 179 | + [[ "${rel_dir}" == *"/deps/"* ]] || \ |
| 180 | + [[ "${rel_dir}" == *"/_build/"* ]] || \ |
| 181 | + [[ "${rel_dir}" == *"/.lake/"* ]] || \ |
| 182 | + [[ "${rel_dir}" == *"/build/"* ]]; then |
| 183 | + continue |
| 184 | + fi |
| 185 | + |
| 186 | + # Normalise: empty relative dir becomes "/" |
| 187 | + if [[ -z "${rel_dir}" ]]; then |
| 188 | + rel_dir="/" |
| 189 | + fi |
| 190 | + |
| 191 | + # Check if this entry already exists |
| 192 | + if entry_exists "${ecosystem}" "${rel_dir}"; then |
| 193 | + echo " OK: ${ecosystem} @ ${rel_dir}" |
| 194 | + else |
| 195 | + append_entry "${ecosystem}" "${rel_dir}" |
| 196 | + changes_made=$((changes_made + 1)) |
| 197 | + fi |
| 198 | + done < <(find "${REPO_ROOT}" -name "${pkg_file}" -not -path "*/.git/*" -not -path "*/node_modules/*" -not -path "*/target/*" -not -path "*/vendor/*" -not -path "*/_build/*" -not -path "*/.lake/*" 2>/dev/null) |
| 199 | +done |
| 200 | + |
| 201 | +echo "" |
| 202 | +if [[ ${changes_made} -gt 0 ]]; then |
| 203 | + echo "=== Done: ${changes_made} entries added ===" |
| 204 | +else |
| 205 | + echo "=== Done: dependabot.yml already has full coverage ===" |
| 206 | +fi |
0 commit comments