Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ceb21b44d5 |
+238
@@ -0,0 +1,238 @@
|
|||||||
|
# Script Review Notes - Improvements & Observations
|
||||||
|
|
||||||
|
**Branch:** `review-improvements`
|
||||||
|
**Date:** 2026-07-20
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📋 Summary Table
|
||||||
|
|
||||||
|
| File | Lines | Type | Overall Rating | Key Issues |
|
||||||
|
|------|-------|------|----------------|------------|
|
||||||
|
| `4-private-ip.sh` | 6 | Simple | ⭐⭐⭐⭐ | Minor - uses deprecated shell syntax |
|
||||||
|
| `4-public-ip.sh` | 44 | IP Lookup | ⭐⭐⭐⭐ | Medium - fallback order could be optimized |
|
||||||
|
| `6-public-ip.sh` | 47 | IPv6 Lookup | ⭐⭐⭐⭐ | Medium - similar to IPv4, duplicated code |
|
||||||
|
| `cloudflare.sh` | 253 | API Wrapper | ⭐⭐⭐ | High - security concerns with tokens in args |
|
||||||
|
| `discord.sh` | 242 | Discord Sender | ⭐⭐⭐⭐ | Low - well documented, minor tweaks needed |
|
||||||
|
| `git-prompt.sh` | 672 | Git Prompt | ⭐⭐⭐ | Medium - large file, some edge cases |
|
||||||
|
| `Dynv6.ps1` | 145 | Powershell DDNS | ⭐⭐⭐⭐ | Low - clean PowerShell code |
|
||||||
|
| `delay.sh` | 20 | Utility | ⭐⭐ | High - uses `bc`, poor error handling |
|
||||||
|
| `df.sh` | 13 | Wrapper | ⭐⭐⭐⭐ | Medium - relies on external script |
|
||||||
|
| `domain-check.sh` | 49 | DNS Checker | ⭐⭐⭐ | Medium - regex fragile, missing error handling |
|
||||||
|
| `dynv6.sh` | 114 | Bash DDNS | ⭐⭐⭐ | Medium - backticks vs `$()` inconsistency |
|
||||||
|
| `fetch-all.sh` | 52 | Git Fetch | ⭐⭐⭐⭐ | Low - clean and functional |
|
||||||
|
| `list-all.sh` | 52 | Git List | ⭐⭐⭐⭐ | Low - mirror of fetch-all.sh |
|
||||||
|
| `pull-all.sh` | 52 | Git Pull | ⭐⭐⭐⭐ | Medium - could check branch upstream first |
|
||||||
|
| `status-all.sh` | 110 | Git Status | ⭐⭐⭐ | High - complex regex matching, colors hardcoded |
|
||||||
|
| `upgrade.sh` | 21 | System Upgrade | ⭐⭐ | High - runs as root by default, no dry-run |
|
||||||
|
| `thermal.sh` | 48 | Thermal Monitor | ⭐⭐⭐ | Medium - assumes `/sys/thermal/*` exists |
|
||||||
|
| `ssh-ident` | 1029 | SSH Manager | ⭐⭐⭐ | Medium - Python-heavy, dependencies |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📝 Detailed Notes by File
|
||||||
|
|
||||||
|
### IP & Network Tools
|
||||||
|
|
||||||
|
#### `4-private-ip.sh`
|
||||||
|
```bash
|
||||||
|
ip -4 addr list scope global | sed -n 's/.*inet \([0-9\.]\+\).*/\1/p' | head -n 1
|
||||||
|
```
|
||||||
|
|
||||||
|
**Observations:**
|
||||||
|
- ✅ Very concise and functional
|
||||||
|
- ⚠️ Uses `sed` with regex that may fail on some edge cases
|
||||||
|
- ⚠️ Comments out alternative (longer) method without explanation
|
||||||
|
|
||||||
|
**Suggestions:**
|
||||||
|
1. Add a fallback using `/usr/sbin/ip6tables` or similar as backup
|
||||||
|
2. Consider: `ip -4 addr show | awk '/inet / {print $2; exit}'`
|
||||||
|
3. Remove commented-out code with note
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### `4-public-ip.sh` & `6-public-ip.sh`
|
||||||
|
**Observations:**
|
||||||
|
- ✅ Good fallback chain (dig → curl → wget)
|
||||||
|
- ⚠️ Uses `-4` flag consistently but no graceful degradation if all fail
|
||||||
|
- ⚠️ Error messages could be more descriptive
|
||||||
|
|
||||||
|
**Suggestions:**
|
||||||
|
1. Add timeout to curl/wget calls: `curl --connect-timeout 5 ...`
|
||||||
|
2. Consider caching the last known IP with TTL
|
||||||
|
3. Add metrics/counter for success rate
|
||||||
|
4. In `6-public-ip.sh`, consider using both IPv4 and IPv6 APIs simultaneously
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### API & Service Integration
|
||||||
|
|
||||||
|
#### `cloudflare.sh`
|
||||||
|
**Observations:**
|
||||||
|
- ✅ Handles `-c`, `-f`, `-q`, `-t` flags well
|
||||||
|
- ⚠️ **Security**: Token/zone defaults loaded from config, but overrides via positional args
|
||||||
|
- ⚠️ Large file (253 lines) - could be modularized
|
||||||
|
|
||||||
|
**Suggestions:**
|
||||||
|
1. Add `--dry-run` flag for testing before commits
|
||||||
|
2. Consider storing the resolved IP in a temp file to avoid repeated lookups
|
||||||
|
3. Add retry logic with exponential backoff for failed API calls
|
||||||
|
4. Parse JSON response more robustly using `jq --argjson ...` pattern
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### `discord.sh`
|
||||||
|
**Observations:**
|
||||||
|
- ✅ Excellent color palette support
|
||||||
|
- ✅ Handles message splitting for long messages
|
||||||
|
- ⚠️ ANSI escape codes embedded in strings may need escaping
|
||||||
|
|
||||||
|
**Suggestions:**
|
||||||
|
1. Consider creating a helper function to build the JSON payload
|
||||||
|
2. Add optional rate-limit header handling (`--wait=true`)
|
||||||
|
3. Log retry attempts if webhook fails
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Git Utilities
|
||||||
|
|
||||||
|
#### `fetch-all.sh`, `list-all.sh`, `pull-all.sh`
|
||||||
|
**Observations:**
|
||||||
|
- ✅ Clean, consistent patterns
|
||||||
|
- ⚠️ `-l` flag description says "follow symbolic links" but sets `-H` (which is actually "follow hardlinks only")
|
||||||
|
- ⚠️ `checkHidden` uses glob pattern that may not work as expected
|
||||||
|
|
||||||
|
**Suggestions:**
|
||||||
|
1. Fix help text: `-L = follow symlinks`, `-H = follow hardlinks`
|
||||||
|
2. Consider adding option to exclude specific paths
|
||||||
|
3. Add progress bar or counter for large repo counts
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### `status-all.sh`
|
||||||
|
**Observations:**
|
||||||
|
- ✅ Very detailed status reporting with colors
|
||||||
|
- ⚠️ Complex regex matching against git output - fragile across versions
|
||||||
|
- ⚠️ Colors hardcoded as escape sequences
|
||||||
|
|
||||||
|
**Suggestions:**
|
||||||
|
1. Use `GIT_PS1_SHOWCOLORHINTS` env var to toggle color rendering
|
||||||
|
2. Extract common status patterns into variables/regex constants
|
||||||
|
3. Add option for "quiet" or "verbose" output modes
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### System & Hardware Tools
|
||||||
|
|
||||||
|
#### `upgrade.sh`
|
||||||
|
**Observations:**
|
||||||
|
- ⚠️ Runs apt commands as root immediately
|
||||||
|
- ⚠️ No dry-run mode to preview changes
|
||||||
|
- ⚠️ Could lock filesystem during update
|
||||||
|
|
||||||
|
**Suggestions:**
|
||||||
|
1. Add `--dry-run` flag that just updates without installing
|
||||||
|
2. Wrap in flock: `flock -n /var/lock/apt.lock apt update ...`
|
||||||
|
3. Create pre/post hooks directory for custom steps
|
||||||
|
4. Report disk space usage before/after
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### `thermal.sh`
|
||||||
|
**Observations:**
|
||||||
|
- ✅ Clean temperature reading loop
|
||||||
|
- ⚠️ Assumes `/sys/class/thermal/*/* -path '*/thermal_*' -name 'temp'` structure
|
||||||
|
|
||||||
|
**Suggestions:**
|
||||||
|
1. Add graceful handling for systems without thermal sensors
|
||||||
|
2. Consider configurable alert thresholds per zone
|
||||||
|
3. Add optional Discord/Pushover notification on alert
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Utilities
|
||||||
|
|
||||||
|
#### `delay.sh`
|
||||||
|
```bash
|
||||||
|
for i in $(seq 1 50); do sleep "${slice}"; echo -n '.'; done
|
||||||
|
```
|
||||||
|
|
||||||
|
**Observations:**
|
||||||
|
- ⚠️ Requires `bc` for floating-point math (not always available)
|
||||||
|
- ⚠️ Output dots could be suppressed with flag
|
||||||
|
- ⚠️ Default range of 1-60 seconds is arbitrary
|
||||||
|
|
||||||
|
**Suggestions:**
|
||||||
|
1. Add `--no-output` flag
|
||||||
|
2. Consider using pure bash arithmetic: `sleep $(awk "BEGIN {printf \"%.3f\", $seconds/50}")`
|
||||||
|
3. Add support for millisecond precision
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### `dynv6.sh`
|
||||||
|
**Observations:**
|
||||||
|
- ✅ Modular design with scope/device options
|
||||||
|
- ⚠️ Uses backticks instead of `$()` (legacy style)
|
||||||
|
- ⚠️ Error handling minimal
|
||||||
|
|
||||||
|
**Suggestions:**
|
||||||
|
1. Convert to `$()` for POSIX compliance
|
||||||
|
2. Add `--test-mode` that validates credentials without updating
|
||||||
|
3. Consider storing last IP in config file for trend analysis
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Large/Complex Files
|
||||||
|
|
||||||
|
#### `git-prompt.sh` (672 lines)
|
||||||
|
- ✅ Feature-rich prompt customization
|
||||||
|
- ⚠️ Requires careful review due to length
|
||||||
|
- ⚠e Many conditional branches with edge cases
|
||||||
|
|
||||||
|
**Quick Wins:**
|
||||||
|
1. Consider extracting sub-functions into separate files
|
||||||
|
2. Add unit tests for key functions
|
||||||
|
3. Document what each environment variable does
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### `ssh-ident` (1029 lines - Python)
|
||||||
|
- ✅ Sophisticated SSH agent management
|
||||||
|
- ⚠️ Requires Python 2.6+ (very broad compatibility)
|
||||||
|
- ⚠️ Large monolithic file
|
||||||
|
|
||||||
|
**Suggestions:**
|
||||||
|
1. Consider packaging as standalone module
|
||||||
|
2. Add `--config-file` override for testing
|
||||||
|
3. Document batch mode behavior more clearly
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎯 Priority Improvements
|
||||||
|
|
||||||
|
### High Priority (Security/Reliability)
|
||||||
|
1. **cloudflare.sh**: Add timeout and retry logic to API calls
|
||||||
|
2. **upgrade.sh**: Implement dry-run and file locking
|
||||||
|
3. All IP lookup scripts: Add timeouts and fallback chains
|
||||||
|
|
||||||
|
### Medium Priority (Maintainability)
|
||||||
|
4. Convert `dynv6.sh` backticks to `$()` syntax
|
||||||
|
5. Extract common patterns from git-* utilities
|
||||||
|
6. Add unit tests for critical functions
|
||||||
|
|
||||||
|
### Low Priority (Nice-to-Have)
|
||||||
|
7. Create shared base classes/modules for IP lookups
|
||||||
|
8. Add logging framework across all scripts
|
||||||
|
9. Consider creating a "master" config file template
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📦 Files to Investigate Further
|
||||||
|
|
||||||
|
- `ssh-ident` - Check Python version requirements in production
|
||||||
|
- `git-prompt.sh` - Verify compatibility with modern git versions
|
||||||
|
- `cloudflare.sh` - Test error handling for rate-limited responses
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Generated on branch: review-improvements*
|
||||||
+1
-1
@@ -46,7 +46,7 @@ function process_docker_pull () {
|
|||||||
|
|
||||||
quietDocker=$(docker compose pull --ignore-buildable --ignore-pull-failures)
|
quietDocker=$(docker compose pull --ignore-buildable --ignore-pull-failures)
|
||||||
|
|
||||||
if docker compose up -d --dry-run 2>&1 | grep -q "Recreate"; then
|
if docker compose up -d --dry-run | grep -q "Recreate"; then
|
||||||
# Only restart services that were already running
|
# Only restart services that were already running
|
||||||
if ((${#runningServices[@]} > 0)); then
|
if ((${#runningServices[@]} > 0)); then
|
||||||
echo "restarting the following services: ${runningServices[@]}"
|
echo "restarting the following services: ${runningServices[@]}"
|
||||||
|
|||||||
-878
@@ -1,878 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
|
|
||||||
import argparse
|
|
||||||
import os
|
|
||||||
import sys
|
|
||||||
from urllib.parse import quote
|
|
||||||
|
|
||||||
import requests
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Configuration
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
GITEA_URL = "https://" + os.environ.get("GITEA_DOMAIN", "").rstrip("/")
|
|
||||||
GITEA_TOKEN = os.environ.get("GITEA_TOKEN", "")
|
|
||||||
USERNAME = os.environ.get("GITEA_ADMIN", "andrew")
|
|
||||||
|
|
||||||
BRANCH = "main"
|
|
||||||
TAG_PATTERN = "v*"
|
|
||||||
|
|
||||||
# Number of repositories to request per API page.
|
|
||||||
PAGE_SIZE = 50
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Desired branch protection configuration
|
|
||||||
#
|
|
||||||
# Only fields listed here are managed by this script.
|
|
||||||
# Other Gitea branch-protection settings are left untouched.
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
BRANCH_DESIRED = {
|
|
||||||
"rule_name": BRANCH,
|
|
||||||
|
|
||||||
# Direct pushes
|
|
||||||
"enable_push": True,
|
|
||||||
"enable_push_whitelist": True,
|
|
||||||
"push_whitelist_usernames": [USERNAME],
|
|
||||||
"push_whitelist_teams": [],
|
|
||||||
"push_whitelist_deploy_keys": False,
|
|
||||||
|
|
||||||
# Force pushes -- explicitly disabled
|
|
||||||
"enable_force_push": False,
|
|
||||||
"enable_force_push_whitelist": False,
|
|
||||||
"force_push_whitelist_usernames": [],
|
|
||||||
"force_push_whitelist_teams": [],
|
|
||||||
"force_push_whitelist_deploy_keys": False,
|
|
||||||
|
|
||||||
# Pull request approvals
|
|
||||||
"required_approvals": 1,
|
|
||||||
"enable_approvals_whitelist": True,
|
|
||||||
"approvals_whitelist_username": [USERNAME],
|
|
||||||
"approvals_whitelist_teams": [],
|
|
||||||
|
|
||||||
# Pull request merging
|
|
||||||
"enable_merge_whitelist": True,
|
|
||||||
"merge_whitelist_usernames": [USERNAME],
|
|
||||||
"merge_whitelist_teams": [],
|
|
||||||
|
|
||||||
# Status checks
|
|
||||||
"enable_status_check": False,
|
|
||||||
"status_check_contexts": [],
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Gitea nullable branch-protection fields
|
|
||||||
#
|
|
||||||
# Gitea returns None for these fields when the corresponding whitelist
|
|
||||||
# functionality is disabled. Treat those values as equivalent to the
|
|
||||||
# explicit values above when comparing configurations.
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
BRANCH_NULL_EQUIVALENTS = {
|
|
||||||
"enable_force_push_whitelist": False,
|
|
||||||
"force_push_whitelist_usernames": [],
|
|
||||||
"force_push_whitelist_teams": [],
|
|
||||||
"force_push_whitelist_deploy_keys": False,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Desired tag protection configuration
|
|
||||||
#
|
|
||||||
# Tags matching TAG_PATTERN are protected. Only USERNAME may create/delete
|
|
||||||
# those tags.
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
TAG_DESIRED = {
|
|
||||||
"name_pattern": TAG_PATTERN,
|
|
||||||
"whitelist_usernames": [USERNAME],
|
|
||||||
"whitelist_teams": [],
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
# When Gitea is installed with DISABLE_GIT_HOOKS=true (the default), the
|
|
||||||
# git-hook API returns 403 for every repository regardless of who the token
|
|
||||||
# belongs to -- even a site admin. The pre-receive guard below cannot be
|
|
||||||
# applied until an admin enables git hooks. We detect this up front (once)
|
|
||||||
# and skip all hook management so the run doesn't spam a 403 per repo.
|
|
||||||
GIT_HOOKS_ENABLED = None # True/False once probed; None = unknown
|
|
||||||
|
|
||||||
# Sentinels the hook helpers raise (instead of requests.HTTPError) so that a
|
|
||||||
# git-hook failure is isolated from branch/tag failures and never marks a
|
|
||||||
# whole repository as FAILED.
|
|
||||||
class HookNotWritable(Exception):
|
|
||||||
"""Raised when the git-hook API rejects us (usually hooks disabled)."""
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Desired Git hook configuration
|
|
||||||
#
|
|
||||||
# The pre-receive hook refuses any push that would add/modify/delete files
|
|
||||||
# under the workflow directories unless the ref is a trusted one (main / v*),
|
|
||||||
# which is already locked down at the permission layer. It is the
|
|
||||||
# server-side backstop that makes it impossible for a feature-branch push to
|
|
||||||
# smuggle in a workflow that could read secrets.
|
|
||||||
#
|
|
||||||
# Gitea stores this content at hooks/pre-receive.d/pre-receive. The
|
|
||||||
# generated hooks/pre-receive wrapper runs every executable file in that
|
|
||||||
# directory, so setting this content is all that is needed for it to take
|
|
||||||
# effect. The API returns the content verbatim, so "already set properly"
|
|
||||||
# is an exact-content match against this constant.
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
GIT_HOOKS = {
|
|
||||||
"pre-receive": r'''#!/usr/bin/env bash
|
|
||||||
# ============================================================================
|
|
||||||
# pre-receive hook: "No workflow changes from untrusted branches"
|
|
||||||
#
|
|
||||||
# WHERE: Gitea Repo -> Settings -> Git Hooks -> "pre-receive" ->
|
|
||||||
# paste this -> Update. (No restart needed; Gitea installs it
|
|
||||||
# server-side in hooks/pre-receive.d/ and its wrapper runs it.)
|
|
||||||
#
|
|
||||||
# WHAT: Any push that would ADD / MODIFY / DELETE files under the
|
|
||||||
# workflow directories is REJECTED before the ref is updated --
|
|
||||||
# UNLESS the ref is one of TRUSTED_REFS (main branch / v* tags),
|
|
||||||
# which you keep locked down at the permission layer.
|
|
||||||
#
|
|
||||||
# WHY: A workflow is code that runs with access to repo/org secrets.
|
|
||||||
# A feature-branch / PR push is untrusted input, so a workflow
|
|
||||||
# arriving that way must never exist server-side. It can't leak
|
|
||||||
# secrets if it was never accepted. Workflow changes can only
|
|
||||||
# land via main or a v* tag -- the refs you already protect.
|
|
||||||
#
|
|
||||||
# NOTES:
|
|
||||||
# * Rejects ANY pushed history that touched the workflow dirs -- even an
|
|
||||||
# add-then-revert pair. The server never records that content. (If you
|
|
||||||
# later want "only the resulting tree matters", switch the existing-ref
|
|
||||||
# branch to a bare `git diff --name-only` and drop the `--not --all`
|
|
||||||
# history scan for new branches.)
|
|
||||||
# * Deletion of any ref is always allowed.
|
|
||||||
# ============================================================================
|
|
||||||
|
|
||||||
WORKFLOW_DIRS=( ".gitea/workflows" ".github/workflows" ) # paths to guard
|
|
||||||
TRUSTED_REFS=( "refs/heads/main" "refs/tags/v*" ) # may touch them
|
|
||||||
|
|
||||||
ZERO="0000000000000000000000000000000000000000"
|
|
||||||
|
|
||||||
is_trusted() {
|
|
||||||
local ref="$1" pat
|
|
||||||
for pat in "${TRUSTED_REFS[@]}"; do
|
|
||||||
# shellcheck disable=SC2254
|
|
||||||
case "$ref" in $pat) return 0 ;; esac
|
|
||||||
done
|
|
||||||
return 1
|
|
||||||
}
|
|
||||||
|
|
||||||
# Prints (one per line) the workflow files a ref update would change.
|
|
||||||
workflow_changes() {
|
|
||||||
local old="$1" new="$2"
|
|
||||||
local args=() i
|
|
||||||
for i in "${WORKFLOW_DIRS[@]}"; do
|
|
||||||
args+=( "$i" )
|
|
||||||
done
|
|
||||||
|
|
||||||
if [ "$old" = "$ZERO" ]; then
|
|
||||||
# New ref: only the commits this push actually introduces matter.
|
|
||||||
git log --format= --name-only --no-renames "$new" --not --all -- "${args[@]}" 2>/dev/null
|
|
||||||
else
|
|
||||||
# Existing ref: net diff between what is there and what will be.
|
|
||||||
git diff --name-only --no-renames "$old" "$new" -- "${args[@]}" 2>/dev/null
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
rejected=0
|
|
||||||
while read -r old new ref; do
|
|
||||||
[ -n "$ref" ] || continue
|
|
||||||
# deletion of a ref is always allowed
|
|
||||||
[ "$new" = "$ZERO" ] && continue
|
|
||||||
# trusted refs (main / v*) may modify workflows
|
|
||||||
is_trusted "$ref" && continue
|
|
||||||
|
|
||||||
changes="$(workflow_changes "$old" "$new")"
|
|
||||||
if [ -n "$changes" ]; then
|
|
||||||
echo "*** [pre-receive] PUSH REJECTED ***" >&2
|
|
||||||
echo "Ref '$ref' changes files under the workflow dirs, which is not allowed." >&2
|
|
||||||
echo "Workflow changes may only arrive via a trusted ref (main / v*):" >&2
|
|
||||||
printf '%s\n' "$changes" | sed 's/^/ /' >&2
|
|
||||||
echo "The push was not accepted; no refs were updated." >&2
|
|
||||||
rejected=1
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
# shellcheck disable=SC2317
|
|
||||||
exit "$rejected"
|
|
||||||
''',
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# API session
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
session = requests.Session()
|
|
||||||
session.headers.update({
|
|
||||||
"Authorization": f"token {GITEA_TOKEN}",
|
|
||||||
"Accept": "application/json",
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
def api(method, path, **kwargs):
|
|
||||||
"""Make a request to the Gitea API."""
|
|
||||||
|
|
||||||
url = f"{GITEA_URL}/api/v1{path}"
|
|
||||||
|
|
||||||
response = session.request(method, url, **kwargs)
|
|
||||||
|
|
||||||
if not response.ok:
|
|
||||||
print(
|
|
||||||
f"ERROR {method} {path}: "
|
|
||||||
f"{response.status_code} {response.text}",
|
|
||||||
file=sys.stderr,
|
|
||||||
)
|
|
||||||
response.raise_for_status()
|
|
||||||
|
|
||||||
if response.status_code == 204:
|
|
||||||
return None
|
|
||||||
|
|
||||||
return response.json()
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Value comparison
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
def values_equal(key, current, desired):
|
|
||||||
"""
|
|
||||||
Compare a value returned by Gitea against the desired value.
|
|
||||||
|
|
||||||
Some Gitea branch-protection fields are returned as None when their
|
|
||||||
associated feature is disabled. Those fields are explicitly handled
|
|
||||||
above so that None and their configured disabled value are equivalent.
|
|
||||||
"""
|
|
||||||
|
|
||||||
if current is None and key in BRANCH_NULL_EQUIVALENTS:
|
|
||||||
return desired == BRANCH_NULL_EQUIVALENTS[key]
|
|
||||||
|
|
||||||
return current == desired
|
|
||||||
|
|
||||||
|
|
||||||
def describe_changes(current, desired, ignored=()):
|
|
||||||
"""
|
|
||||||
Return human-readable descriptions of managed fields that differ.
|
|
||||||
"""
|
|
||||||
|
|
||||||
changes = []
|
|
||||||
|
|
||||||
for key, wanted in desired.items():
|
|
||||||
if key in ignored:
|
|
||||||
continue
|
|
||||||
|
|
||||||
actual = current.get(key)
|
|
||||||
|
|
||||||
if not values_equal(key, actual, wanted):
|
|
||||||
changes.append(
|
|
||||||
f"{key}: {actual!r} -> {wanted!r}"
|
|
||||||
)
|
|
||||||
|
|
||||||
return changes
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Repository enumeration
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
def get_all_repositories():
|
|
||||||
"""Enumerate every repository the authenticated user can administer."""
|
|
||||||
|
|
||||||
repos = []
|
|
||||||
page = 1
|
|
||||||
|
|
||||||
while True:
|
|
||||||
batch = api(
|
|
||||||
"GET",
|
|
||||||
"/user/repos",
|
|
||||||
params={
|
|
||||||
"limit": PAGE_SIZE,
|
|
||||||
"page": page,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
if not batch:
|
|
||||||
break
|
|
||||||
|
|
||||||
repos.extend(batch)
|
|
||||||
|
|
||||||
if len(batch) < PAGE_SIZE:
|
|
||||||
break
|
|
||||||
|
|
||||||
page += 1
|
|
||||||
|
|
||||||
return repos
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Branch protection
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
def get_branch_protection(owner, repo):
|
|
||||||
"""Return the existing protection for BRANCH, or None."""
|
|
||||||
|
|
||||||
protections = api(
|
|
||||||
"GET",
|
|
||||||
f"/repos/{quote(owner)}/{quote(repo)}/branch_protections",
|
|
||||||
)
|
|
||||||
|
|
||||||
for protection in protections:
|
|
||||||
if protection.get("rule_name") == BRANCH:
|
|
||||||
return protection
|
|
||||||
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def describe_branch_protection(protection):
|
|
||||||
"""Return a concise description of the current branch protection."""
|
|
||||||
|
|
||||||
if protection is None:
|
|
||||||
return "NO PROTECTION"
|
|
||||||
|
|
||||||
return (
|
|
||||||
f"push={protection.get('enable_push')} "
|
|
||||||
f"push_allowlist={protection.get('push_whitelist_usernames')} "
|
|
||||||
f"force_push={protection.get('enable_force_push')} "
|
|
||||||
f"approvals={protection.get('required_approvals')} "
|
|
||||||
f"approval_allowlist="
|
|
||||||
f"{protection.get('approvals_whitelist_username')} "
|
|
||||||
f"merge_allowlist="
|
|
||||||
f"{protection.get('merge_whitelist_usernames')}"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def apply_branch_protection(owner, repo, existing):
|
|
||||||
"""Create or update the branch protection."""
|
|
||||||
|
|
||||||
encoded_owner = quote(owner)
|
|
||||||
encoded_repo = quote(repo)
|
|
||||||
|
|
||||||
if existing is None:
|
|
||||||
api(
|
|
||||||
"POST",
|
|
||||||
f"/repos/{encoded_owner}/{encoded_repo}/branch_protections",
|
|
||||||
json=BRANCH_DESIRED,
|
|
||||||
)
|
|
||||||
|
|
||||||
return "CREATED"
|
|
||||||
|
|
||||||
# PATCH only the fields explicitly managed by this script.
|
|
||||||
payload = {
|
|
||||||
key: value
|
|
||||||
for key, value in BRANCH_DESIRED.items()
|
|
||||||
if key != "rule_name"
|
|
||||||
}
|
|
||||||
|
|
||||||
api(
|
|
||||||
"PATCH",
|
|
||||||
f"/repos/{encoded_owner}/{encoded_repo}/branch_protections/"
|
|
||||||
f"{quote(BRANCH)}",
|
|
||||||
json=payload,
|
|
||||||
)
|
|
||||||
|
|
||||||
return "UPDATED"
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Tag protection
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
def get_tag_protection(owner, repo):
|
|
||||||
"""Return the existing protection for TAG_PATTERN, or None."""
|
|
||||||
|
|
||||||
protections = api(
|
|
||||||
"GET",
|
|
||||||
f"/repos/{quote(owner)}/{quote(repo)}/tag_protections",
|
|
||||||
)
|
|
||||||
|
|
||||||
for protection in protections:
|
|
||||||
if protection.get("name_pattern") == TAG_PATTERN:
|
|
||||||
return protection
|
|
||||||
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def describe_tag_protection(protection):
|
|
||||||
"""Return a concise description of the current tag protection."""
|
|
||||||
|
|
||||||
if protection is None:
|
|
||||||
return "NO PROTECTION"
|
|
||||||
|
|
||||||
return (
|
|
||||||
f"users={protection.get('whitelist_usernames')} "
|
|
||||||
f"teams={protection.get('whitelist_teams')}"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def apply_tag_protection(owner, repo, existing):
|
|
||||||
"""Create or update the tag protection."""
|
|
||||||
|
|
||||||
encoded_owner = quote(owner)
|
|
||||||
encoded_repo = quote(repo)
|
|
||||||
|
|
||||||
if existing is None:
|
|
||||||
api(
|
|
||||||
"POST",
|
|
||||||
f"/repos/{encoded_owner}/{encoded_repo}/tag_protections",
|
|
||||||
json=TAG_DESIRED,
|
|
||||||
)
|
|
||||||
|
|
||||||
return "CREATED"
|
|
||||||
|
|
||||||
# PATCH only the fields explicitly managed by this script.
|
|
||||||
payload = {
|
|
||||||
key: value
|
|
||||||
for key, value in TAG_DESIRED.items()
|
|
||||||
if key != "name_pattern"
|
|
||||||
}
|
|
||||||
|
|
||||||
api(
|
|
||||||
"PATCH",
|
|
||||||
f"/repos/{encoded_owner}/{encoded_repo}/tag_protections/"
|
|
||||||
f"{quote(str(existing['id']))}",
|
|
||||||
json=payload,
|
|
||||||
)
|
|
||||||
|
|
||||||
return "UPDATED"
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Git hooks
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
def hook_request_allowed(response):
|
|
||||||
"""Return True if the response means hooks are usable, False if the
|
|
||||||
server rejected the request (403 -> DISABLE_GIT_HOOKS), and raise if
|
|
||||||
the failure is something else we should surface."""
|
|
||||||
|
|
||||||
if response.ok:
|
|
||||||
return True
|
|
||||||
|
|
||||||
if response.status_code == 403:
|
|
||||||
# Gitea returns this when the authenticated user cannot manage git
|
|
||||||
# hooks, which happens whenever DISABLE_GIT_HOOKS is true (even for
|
|
||||||
# admins). Treat it as "hooks disabled / not writable".
|
|
||||||
return False
|
|
||||||
|
|
||||||
return response.raise_for_status()
|
|
||||||
|
|
||||||
|
|
||||||
def get_git_hook(owner, repo, hook_name):
|
|
||||||
"""
|
|
||||||
Return the existing {hook_name} hook, or None if it is not set.
|
|
||||||
|
|
||||||
Gitea returns is_active=false with empty content when the hook is not
|
|
||||||
configured. Treat that as "not set" so it is reported and applied
|
|
||||||
like the branch/tag protections below.
|
|
||||||
|
|
||||||
Raises HookNotWritable when the git-hook API is not usable (e.g. git
|
|
||||||
hooks disabled), so callers can report it without failing the repo.
|
|
||||||
"""
|
|
||||||
|
|
||||||
url = (
|
|
||||||
f"/repos/{quote(owner)}/{quote(repo)}/hooks/git/"
|
|
||||||
f"{quote(hook_name)}"
|
|
||||||
)
|
|
||||||
response = session.request("GET", f"{GITEA_URL}/api/v1{url}")
|
|
||||||
|
|
||||||
if not hook_request_allowed(response):
|
|
||||||
raise HookNotWritable(url)
|
|
||||||
|
|
||||||
hook = response.json()
|
|
||||||
|
|
||||||
if not hook.get("is_active"):
|
|
||||||
return None
|
|
||||||
|
|
||||||
return hook
|
|
||||||
|
|
||||||
|
|
||||||
def describe_git_hook(hook):
|
|
||||||
"""Return a concise description of the current hook state."""
|
|
||||||
|
|
||||||
if hook is None:
|
|
||||||
return "NOT SET"
|
|
||||||
|
|
||||||
return "SET"
|
|
||||||
|
|
||||||
|
|
||||||
def apply_git_hook(owner, repo, hook_name):
|
|
||||||
"""Set the hook content to the desired value."""
|
|
||||||
|
|
||||||
url = (
|
|
||||||
f"/repos/{quote(owner)}/{quote(repo)}/hooks/git/"
|
|
||||||
f"{quote(hook_name)}"
|
|
||||||
)
|
|
||||||
response = session.request(
|
|
||||||
"PATCH",
|
|
||||||
f"{GITEA_URL}/api/v1{url}",
|
|
||||||
json={"content": GIT_HOOKS[hook_name]},
|
|
||||||
)
|
|
||||||
|
|
||||||
if not hook_request_allowed(response):
|
|
||||||
raise HookNotWritable(url)
|
|
||||||
|
|
||||||
response.raise_for_status()
|
|
||||||
return "UPDATED"
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Main
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
def main():
|
|
||||||
parser = argparse.ArgumentParser(
|
|
||||||
description=(
|
|
||||||
"Apply standardized Gitea branch and tag protection."
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
parser.add_argument(
|
|
||||||
"--apply",
|
|
||||||
action="store_true",
|
|
||||||
help=(
|
|
||||||
"Actually modify repositories. Without this, only show "
|
|
||||||
"what would happen."
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
args = parser.parse_args()
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------
|
|
||||||
# Validate configuration
|
|
||||||
# -----------------------------------------------------------------------
|
|
||||||
|
|
||||||
if not GITEA_URL or GITEA_URL == "https://":
|
|
||||||
print("GITEA_DOMAIN is not set.", file=sys.stderr)
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
if not GITEA_TOKEN:
|
|
||||||
print("GITEA_TOKEN is not set.", file=sys.stderr)
|
|
||||||
sys.exit(1)
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------
|
|
||||||
# Header
|
|
||||||
# -----------------------------------------------------------------------
|
|
||||||
|
|
||||||
print(f"Gitea: {GITEA_URL}")
|
|
||||||
print(f"Branch: {BRANCH}")
|
|
||||||
print(f"Tag pattern: {TAG_PATTERN}")
|
|
||||||
print(f"User: {USERNAME}")
|
|
||||||
print()
|
|
||||||
|
|
||||||
if not args.apply:
|
|
||||||
print("*** DRY RUN ***")
|
|
||||||
print("Use --apply to actually make changes.")
|
|
||||||
print()
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------
|
|
||||||
# Enumerate repositories
|
|
||||||
# -----------------------------------------------------------------------
|
|
||||||
|
|
||||||
print("Enumerating repositories...")
|
|
||||||
|
|
||||||
repositories = get_all_repositories()
|
|
||||||
|
|
||||||
print(f"Found {len(repositories)} repositories.")
|
|
||||||
print()
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------
|
|
||||||
# Probe whether the git-hook API is usable
|
|
||||||
# -----------------------------------------------------------------------
|
|
||||||
|
|
||||||
# Gitea returns 403 (even for admins) when DISABLE_GIT_HOOKS is true --
|
|
||||||
# the default. Detect that once, up front, instead of failing every
|
|
||||||
# repo's hook step. Use the first non-archived repo as the probe target.
|
|
||||||
# Initialize hooks_enabled before the probe so every code path has it.
|
|
||||||
|
|
||||||
hooks_enabled = True
|
|
||||||
|
|
||||||
probe_repo = next(
|
|
||||||
(r for r in repositories if not r.get("archived", False)),
|
|
||||||
None,
|
|
||||||
)
|
|
||||||
|
|
||||||
if probe_repo is None:
|
|
||||||
print("No editable repositories found; nothing to do.")
|
|
||||||
return
|
|
||||||
|
|
||||||
probe_owner = probe_repo["owner"]["login"]
|
|
||||||
probe_name = probe_repo["name"]
|
|
||||||
|
|
||||||
try:
|
|
||||||
get_git_hook(probe_owner, probe_name, "pre-receive")
|
|
||||||
print("Git hooks: enabled (git-hook API reachable)")
|
|
||||||
except HookNotWritable:
|
|
||||||
hooks_enabled = False
|
|
||||||
print(
|
|
||||||
"Git hooks: DISABLED/not writable -- pre-receive hook will be "
|
|
||||||
"skipped. Enable git hooks in Gitea (DISABLE_GIT_HOOKS=false "
|
|
||||||
"+ admin) to use the pre-receive guard."
|
|
||||||
)
|
|
||||||
except requests.HTTPError:
|
|
||||||
# Some other API failure on the probe. Don't assume hooks are
|
|
||||||
# disabled; just note we couldn't verify and continue per-repo.
|
|
||||||
print(
|
|
||||||
"Git hooks: could not verify (API error) -- will attempt per repo"
|
|
||||||
)
|
|
||||||
hooks_enabled = True
|
|
||||||
print()
|
|
||||||
|
|
||||||
if not hooks_enabled:
|
|
||||||
print(
|
|
||||||
"*** Git hooks are disabled; skipping the pre-receive hook step "
|
|
||||||
"for all repositories. Branch/tag protection is unaffected. ***"
|
|
||||||
)
|
|
||||||
print()
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------
|
|
||||||
# Counters
|
|
||||||
# -----------------------------------------------------------------------
|
|
||||||
|
|
||||||
branch_changed = 0
|
|
||||||
branch_unchanged = 0
|
|
||||||
|
|
||||||
tag_changed = 0
|
|
||||||
tag_unchanged = 0
|
|
||||||
|
|
||||||
hook_changed = 0
|
|
||||||
hook_unchanged = 0
|
|
||||||
hook_skipped = 0
|
|
||||||
hooks_enabled = True
|
|
||||||
|
|
||||||
skipped = 0
|
|
||||||
failed = 0
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------
|
|
||||||
# Process repositories
|
|
||||||
# -----------------------------------------------------------------------
|
|
||||||
|
|
||||||
for repo in repositories:
|
|
||||||
owner = repo["owner"]["login"]
|
|
||||||
name = repo["name"]
|
|
||||||
|
|
||||||
print(f"[{owner}/{name}]")
|
|
||||||
|
|
||||||
# Archived repositories cannot have their protection modified.
|
|
||||||
if repo.get("archived", False):
|
|
||||||
print(" SKIP: archived")
|
|
||||||
skipped += 1
|
|
||||||
print()
|
|
||||||
continue
|
|
||||||
|
|
||||||
try:
|
|
||||||
# ---------------------------------------------------------------
|
|
||||||
# Branch protection
|
|
||||||
# ---------------------------------------------------------------
|
|
||||||
|
|
||||||
protection = get_branch_protection(owner, name)
|
|
||||||
|
|
||||||
print(
|
|
||||||
f" Branch: {describe_branch_protection(protection)}"
|
|
||||||
)
|
|
||||||
|
|
||||||
if protection is None:
|
|
||||||
print(
|
|
||||||
f" Would CREATE protection for {BRANCH}"
|
|
||||||
)
|
|
||||||
|
|
||||||
if args.apply:
|
|
||||||
result = apply_branch_protection(
|
|
||||||
owner,
|
|
||||||
name,
|
|
||||||
protection,
|
|
||||||
)
|
|
||||||
print(f" {result}")
|
|
||||||
|
|
||||||
branch_changed += 1
|
|
||||||
|
|
||||||
else:
|
|
||||||
branch_changes = describe_changes(
|
|
||||||
protection,
|
|
||||||
BRANCH_DESIRED,
|
|
||||||
ignored=("rule_name",),
|
|
||||||
)
|
|
||||||
|
|
||||||
if not branch_changes:
|
|
||||||
print(" OK: already matches")
|
|
||||||
branch_unchanged += 1
|
|
||||||
|
|
||||||
else:
|
|
||||||
print(
|
|
||||||
f" Would UPDATE protection for {BRANCH}"
|
|
||||||
)
|
|
||||||
|
|
||||||
for change in branch_changes:
|
|
||||||
print(f" {change}")
|
|
||||||
|
|
||||||
if args.apply:
|
|
||||||
result = apply_branch_protection(
|
|
||||||
owner,
|
|
||||||
name,
|
|
||||||
protection,
|
|
||||||
)
|
|
||||||
print(f" {result}")
|
|
||||||
|
|
||||||
branch_changed += 1
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------
|
|
||||||
# Tag protection
|
|
||||||
# ---------------------------------------------------------------
|
|
||||||
|
|
||||||
tag_protection = get_tag_protection(owner, name)
|
|
||||||
|
|
||||||
print(
|
|
||||||
f" Tags: {describe_tag_protection(tag_protection)}"
|
|
||||||
)
|
|
||||||
|
|
||||||
if tag_protection is None:
|
|
||||||
print(
|
|
||||||
f" Would CREATE protection for {TAG_PATTERN}"
|
|
||||||
)
|
|
||||||
|
|
||||||
if args.apply:
|
|
||||||
result = apply_tag_protection(
|
|
||||||
owner,
|
|
||||||
name,
|
|
||||||
tag_protection,
|
|
||||||
)
|
|
||||||
print(f" {result}")
|
|
||||||
|
|
||||||
tag_changed += 1
|
|
||||||
|
|
||||||
else:
|
|
||||||
tag_changes = describe_changes(
|
|
||||||
tag_protection,
|
|
||||||
TAG_DESIRED,
|
|
||||||
ignored=("name_pattern",),
|
|
||||||
)
|
|
||||||
|
|
||||||
if not tag_changes:
|
|
||||||
print(" OK: already matches")
|
|
||||||
tag_unchanged += 1
|
|
||||||
|
|
||||||
else:
|
|
||||||
print(
|
|
||||||
f" Would UPDATE protection for {TAG_PATTERN}"
|
|
||||||
)
|
|
||||||
|
|
||||||
for change in tag_changes:
|
|
||||||
print(f" {change}")
|
|
||||||
|
|
||||||
if args.apply:
|
|
||||||
result = apply_tag_protection(
|
|
||||||
owner,
|
|
||||||
name,
|
|
||||||
tag_protection,
|
|
||||||
)
|
|
||||||
print(f" {result}")
|
|
||||||
|
|
||||||
tag_changed += 1
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------
|
|
||||||
# Pre-receive Git hook
|
|
||||||
# ---------------------------------------------------------------
|
|
||||||
|
|
||||||
if hooks_enabled:
|
|
||||||
try:
|
|
||||||
hook = get_git_hook(owner, name, "pre-receive")
|
|
||||||
|
|
||||||
if hook is None:
|
|
||||||
print(" Hook: NOT SET")
|
|
||||||
print(" Would SET pre-receive hook")
|
|
||||||
|
|
||||||
if args.apply:
|
|
||||||
result = apply_git_hook(
|
|
||||||
owner,
|
|
||||||
name,
|
|
||||||
"pre-receive",
|
|
||||||
)
|
|
||||||
print(f" {result}")
|
|
||||||
|
|
||||||
hook_changed += 1
|
|
||||||
|
|
||||||
elif hook.get("content") == GIT_HOOKS["pre-receive"]:
|
|
||||||
print(
|
|
||||||
" Hook: SET (matches desired content)"
|
|
||||||
)
|
|
||||||
hook_unchanged += 1
|
|
||||||
|
|
||||||
else:
|
|
||||||
print(" Hook: SET (content differs)")
|
|
||||||
print(" Would UPDATE pre-receive hook")
|
|
||||||
|
|
||||||
if args.apply:
|
|
||||||
result = apply_git_hook(
|
|
||||||
owner,
|
|
||||||
name,
|
|
||||||
"pre-receive",
|
|
||||||
)
|
|
||||||
print(f" {result}")
|
|
||||||
|
|
||||||
hook_changed += 1
|
|
||||||
|
|
||||||
except HookNotWritable:
|
|
||||||
print(
|
|
||||||
" Hook: SKIPPED (git hooks not writable)"
|
|
||||||
)
|
|
||||||
hook_skipped += 1
|
|
||||||
|
|
||||||
else:
|
|
||||||
print(
|
|
||||||
" Hook: SKIPPED (git hooks disabled)"
|
|
||||||
)
|
|
||||||
hook_skipped += 1
|
|
||||||
|
|
||||||
except requests.HTTPError:
|
|
||||||
print(" FAILED")
|
|
||||||
failed += 1
|
|
||||||
|
|
||||||
print()
|
|
||||||
|
|
||||||
# -----------------------------------------------------------------------
|
|
||||||
# Summary
|
|
||||||
# -----------------------------------------------------------------------
|
|
||||||
|
|
||||||
print("----------------------------------------")
|
|
||||||
print(f"Repositories: {len(repositories)}")
|
|
||||||
print()
|
|
||||||
print(f"Branch changed: {branch_changed}")
|
|
||||||
print(f"Branch unchanged: {branch_unchanged}")
|
|
||||||
print()
|
|
||||||
print(f"Tags changed: {tag_changed}")
|
|
||||||
print(f"Tags unchanged: {tag_unchanged}")
|
|
||||||
print()
|
|
||||||
print(f"Hooks changed: {hook_changed}")
|
|
||||||
print(f"Hooks unchanged: {hook_unchanged}")
|
|
||||||
if not hooks_enabled:
|
|
||||||
print(f"Hooks skipped: {hook_skipped} (git hooks disabled)")
|
|
||||||
elif hook_skipped:
|
|
||||||
print(f"Hooks skipped: {hook_skipped} (not writable)")
|
|
||||||
else:
|
|
||||||
print(f"Hooks skipped: {hook_skipped}")
|
|
||||||
print()
|
|
||||||
print(f"Skipped: {skipped}")
|
|
||||||
print(f"Failed: {failed}")
|
|
||||||
|
|
||||||
if not args.apply:
|
|
||||||
print()
|
|
||||||
print("Dry run complete. Nothing was changed.")
|
|
||||||
|
|
||||||
# If the hook step had to be skipped (e.g. git hooks disabled), this run
|
|
||||||
# is only partially complete. Exit non-zero so automation notices.
|
|
||||||
if not hooks_enabled or hook_skipped:
|
|
||||||
sys.exit(2)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
main()
|
|
||||||
|
|
||||||
Reference in New Issue
Block a user