5 Commits
Author SHA1 Message Date
andrew 860b2346c6 added tag protection, also fixed so it will properly detect when no change is needed.
Sync GitHub / sync (push) Successful in 9s
2026-08-20 08:50:31 -04:00
andrew e7366188ac new script to enforce gitea branch protection
Sync GitHub / sync (push) Successful in 7s
2026-08-19 10:36:35 -04:00
andrew dfd12a10cb fixed docker pull
Sync GitHub / sync (push) Successful in 7s
2026-07-25 22:43:19 -04:00
andrew 09dffc3933 new docker scripts, pull and heal.
Sync GitHub / sync (push) Successful in 40s
2026-07-24 23:43:31 -04:00
andrew aa0da565e1 setting up automatic sync from Gitea to GitHub
Sync GitHub / sync (push) Successful in 9s
2026-06-14 11:05:28 -04:00
5 changed files with 747 additions and 0 deletions
+37
View File
@@ -0,0 +1,37 @@
name: Sync GitHub
on:
push:
branches:
- '**'
jobs:
sync:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Configure Git
run: |
git config --global user.name "Andrew Sync"
git config --global user.email "sync@digitaladapt.com"
- name: Add GitHub Remote
env:
SYNC_TOKEN: ${{ secrets.SYNC_GITHUB_TOKEN }}
SYNC_TARGET: ${{ vars.SYNC_GITHUB_TARGET }}
run: |
git remote add github "https://digitaladapt:${SYNC_TOKEN}@github.com/$SYNC_TARGET"
- name: Push Current Branch
run: |
git push github HEAD:${GITHUB_REF_NAME}
- name: Push Tags
run: |
git push github --tags
+68
View File
@@ -0,0 +1,68 @@
#!/usr/bin/env bash
set -euo pipefail
notify() {
local color="$1"
local msg="$2"
local hostname=$(hostname)
echo "$msg"
ntfy pub -T "${color}_square" -t "Docker Health on ${hostname}" docker "$msg" >/dev/null 2>&1 || true
}
declare -A compose_projects
declare -a standalone_containers
# Find unhealthy containers
while read -r container; do
health=$(docker inspect \
--format '{{if .State.Health}}{{.State.Health.Status}}{{else}}none{{end}}' \
"$container")
# Ignore missing health checks and containers still starting
if [[ "$health" != "unhealthy" ]]; then
continue
fi
project_dir=$(docker inspect \
--format '{{index .Config.Labels "com.docker.compose.project.working_dir"}}' \
"$container")
if [[ -n "$project_dir" && "$project_dir" != "<no value>" ]]; then
compose_projects["$project_dir"]+="$container "
else
standalone_containers+=("$container")
fi
done < <(docker ps -q)
# Restart compose projects once each
for project_dir in "${!compose_projects[@]}"; do
containers="${compose_projects[$project_dir]}"
notify "orange" "Unhealthy containers in $(basename "$project_dir"): $containers"
if [[ -f "$project_dir/compose.yaml" ]]; then
if docker compose \
--project-directory "$project_dir" \
up -d --force-recreate; then
notify "green" "Recovery succeeded: $(basename "$project_dir")"
else
notify "red" "Recovery FAILED: $(basename "$project_dir")"
fi
else
notify "black" "Missing compose.yaml in $project_dir"
fi
done
# Restart standalone containers
for container in "${standalone_containers[@]}"; do
notify "orange" "Unhealthy standalone container $container"
if docker restart "$container" >/dev/null; then
notify "green" "Recovery succeeded: $container"
else
notify "red" "Recovery FAILED: $container"
fi
done
Executable
+73
View File
@@ -0,0 +1,73 @@
#!/usr/bin/env bash
# perform a "docker compose pull" on each location specified (recursively), or current location if none specified.
#
# will only search symbolic links when passed "-l" option, must be before any locations
# will only search hidden directories when passed "-h" option, must be before any locations
# pull-all.sh [-l][-h] # current location implied
# pull-all.sh [-l][-h] ~/my-projects /var/group-projects
# check for "-l" and "-h" in command prompt
followLinks="-H"
checkHidden="*/.*/*compose.yaml"
while getopts "lh" option; do
case $option in
l)
followLinks="-L"
;;
h)
checkHidden=""
;;
esac
done
shift "$((OPTIND-1))"
if [[ "-H" == "$followLinks" ]]; then
echo "use -l to follow symbolic links"
fi
if [[ -n "$checkHidden" ]]; then
echo "use -h to check hidden directories"
fi
# function to process each location
function process_docker_pull () {
startedIn=`pwd`
location=$(realpath $(dirname "$@"))
line="--- --- --- --- --- --- --- --- ---"
# use "--" to tell printf there are no more commands, only strings to print
printf -- "--- docker compose pull %s %s ---\n" "$location" "${line:${#location}}"
cd "$location"
# populate $runningServices[]
mapfile -t runningServices < <(
docker compose ps --services --status running
)
quietDocker=$(docker compose pull --ignore-buildable --ignore-pull-failures)
if docker compose up -d --dry-run 2>&1 | grep -q "Recreate"; then
# Only restart services that were already running
if ((${#runningServices[@]} > 0)); then
echo "restarting the following services: ${runningServices[@]}"
quietDocker=$(docker compose up -d "${runningServices[@]}")
else
echo "updates downloaded, no services were running"
fi
else
messageSuffix=""
if ((${#runningServices[@]} > 0)); then
messageSuffix=", services already current: ${runningServices[@]}"
fi
echo "no changes detected$messageSuffix"
fi
cd "$startedIn"
}
# find all folders named ".git" under given locations,
# and call process_git_pull on each location that was found.
# we then work on the folder that contained the ".git" folder.
find "$followLinks" "$@" -type f \( -name 'compose.yaml' -o -name 'docker-compose.yaml' \) -not -path "$checkHidden" | sort | while read -r file; do process_docker_pull "$file"; done
+563
View File
@@ -0,0 +1,563 @@
#!/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": [],
}
# ---------------------------------------------------------------------------
# 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"
# ---------------------------------------------------------------------------
# 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()
# -----------------------------------------------------------------------
# Counters
# -----------------------------------------------------------------------
branch_changed = 0
branch_unchanged = 0
tag_changed = 0
tag_unchanged = 0
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
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"Skipped: {skipped}")
print(f"Failed: {failed}")
if not args.apply:
print()
print("Dry run complete. Nothing was changed.")
if __name__ == "__main__":
main()
+6
View File
@@ -66,6 +66,12 @@ Plug 'airblade/vim-gitgutter'
Plug 'vim-airline/vim-airline'
Plug 'vim-airline/vim-airline-themes'
" twig support (php templating)
Plug 'lumiliet/vim-twig'
" AnsiEsc to make editing colorful files better
Plug 'powerman/vim-plugin-AnsiEsc'
call plug#end()
" config git-gutter