This commit is contained in:
NuklearRabbit
2026-07-24 20:29:23 +02:00
commit 66060348da
107 changed files with 14771 additions and 0 deletions
+148
View File
@@ -0,0 +1,148 @@
#!/usr/bin/env bash
set -Eeuo pipefail
umask 027
# Install as /usr/local/bin/forgeflow-deploy, owned by root and not writable by
# the Gitea runner. Targets are read from the root-owned data file below.
readonly CONFIG_FILE="/etc/forgeflow/targets.conf"
readonly REPOSITORY="${1:-}"
readonly ENVIRONMENT="${2:-}"
readonly SHA="${3:-}"
readonly REQUEST_ID="${4:-manual-$(date +%s)}"
fail_usage() {
echo "Usage: forgeflow-deploy <owner/repository> <environment> <full-sha> [request-id]" >&2
exit 64
}
[[ "$REPOSITORY" =~ ^[A-Za-z0-9._-]+/[A-Za-z0-9._-]+$ ]] || fail_usage
[[ "$ENVIRONMENT" =~ ^[A-Za-z0-9._-]+$ ]] || fail_usage
[[ "$SHA" =~ ^[0-9a-fA-F]{40,64}$ ]] || fail_usage
[[ "$REQUEST_ID" =~ ^[A-Za-z0-9._:-]{1,100}$ ]] || fail_usage
[[ -f "$CONFIG_FILE" ]] || { echo "Missing target configuration: $CONFIG_FILE" >&2; exit 78; }
# The target file is security-sensitive because it controls root-executed paths.
config_owner="$(stat -c '%U' "$CONFIG_FILE")"
config_mode="$(stat -c '%a' "$CONFIG_FILE")"
[[ "$config_owner" == "root" ]] || { echo "Target configuration must be owned by root" >&2; exit 78; }
# Reject group/other write bits. GNU stat returns an octal string such as 640.
(( (8#$config_mode & 8#022) == 0 )) || { echo "Target configuration may not be group/other writable" >&2; exit 78; }
APP_DIR=""
BRANCH=""
COMPOSE_FILE=""
HEALTHCHECK_URL=""
STATUS_FILE=""
while IFS='|' read -r config_repository config_environment config_app_dir config_branch config_compose config_health config_status extra; do
[[ -z "${config_repository// }" || "$config_repository" == \#* ]] && continue
[[ -z "${extra:-}" ]] || { echo "Invalid extra field in $CONFIG_FILE" >&2; exit 78; }
if [[ "$config_repository" == "$REPOSITORY" && "$config_environment" == "$ENVIRONMENT" ]]; then
APP_DIR="$config_app_dir"
BRANCH="$config_branch"
COMPOSE_FILE="$config_compose"
HEALTHCHECK_URL="$config_health"
STATUS_FILE="$config_status"
break
fi
done < "$CONFIG_FILE"
[[ -n "$APP_DIR" ]] || { echo "Repository/environment is not allowlisted" >&2; exit 64; }
[[ "$APP_DIR" == /* && "$COMPOSE_FILE" == /* && "$STATUS_FILE" == /var/lib/forgeflow-status/* ]] || {
echo "Target configuration contains an unsafe path" >&2
exit 78
}
[[ "$BRANCH" =~ ^[A-Za-z0-9._/-]+$ && "$BRANCH" != *..* ]] || { echo "Unsafe branch in target configuration" >&2; exit 78; }
[[ -d "$APP_DIR/.git" ]] || { echo "Application directory is not a Git working tree: $APP_DIR" >&2; exit 72; }
[[ -f "$COMPOSE_FILE" ]] || { echo "Compose file does not exist: $COMPOSE_FILE" >&2; exit 72; }
install -d -o root -g root -m 0755 "$(dirname "$STATUS_FILE")"
json_string() {
# Inputs accepted by this script are deliberately restricted to characters
# that do not need JSON escaping. This guard prevents accidental expansion.
[[ "$1" =~ ^[A-Za-z0-9._:/-]*$ ]] || return 1
printf '%s' "$1"
}
write_status() {
local health="$1"
local live_sha="$2"
local previous_sha="$3"
local exit_code="${4:-0}"
local deployed_at temporary
deployed_at="$(date --utc +%Y-%m-%dT%H:%M:%SZ)"
temporary="${STATUS_FILE}.${$}.tmp"
json_string "$health" >/dev/null
json_string "$REQUEST_ID" >/dev/null
[[ "$live_sha" =~ ^[0-9a-fA-F]{40,64}$ ]] || { echo "Invalid live SHA for status output" >&2; return 1; }
[[ "$previous_sha" =~ ^[0-9a-fA-F]{40,64}$ ]] || { echo "Invalid previous SHA for status output" >&2; return 1; }
cat > "$temporary" <<JSON
{
"repository": "$REPOSITORY",
"environment": "$ENVIRONMENT",
"request_id": "$REQUEST_ID",
"commit_sha": "$live_sha",
"previous_sha": "$previous_sha",
"requested_sha": "$SHA",
"deployed_at": "$deployed_at",
"health": "$health",
"last_exit_code": $exit_code
}
JSON
chmod 0644 "$temporary"
mv -f "$temporary" "$STATUS_FILE"
}
exec 9>"/run/lock/forgeflow-${REPOSITORY//\//-}-${ENVIRONMENT}.lock"
flock -n 9 || { echo "Another deployment is already running" >&2; exit 75; }
current_sha="$(git -C "$APP_DIR" rev-parse HEAD)"
previous_sha="$(cat "$APP_DIR/.forgeflow-live-sha" 2>/dev/null || printf '%s' "$current_sha")"
[[ "$previous_sha" =~ ^[0-9a-fA-F]{40,64}$ ]] || previous_sha="$current_sha"
echo "ForgeFlow request: $REQUEST_ID"
echo "Target: $REPOSITORY / $ENVIRONMENT"
echo "Current SHA: $current_sha"
echo "Requested SHA: $SHA"
on_error() {
local exit_code=$?
local actual_sha
trap - ERR
actual_sha="$(git -C "$APP_DIR" rev-parse HEAD 2>/dev/null || printf '%s' "$current_sha")"
write_status "unhealthy" "$actual_sha" "$previous_sha" "$exit_code" || true
echo "Deployment failed with exit code $exit_code" >&2
exit "$exit_code"
}
trap on_error ERR
git -C "$APP_DIR" fetch --prune origin "$BRANCH"
git -C "$APP_DIR" cat-file -e "$SHA^{commit}"
git -C "$APP_DIR" merge-base --is-ancestor "$SHA" "origin/$BRANCH" || {
echo "Requested SHA is not part of origin/$BRANCH" >&2
exit 65
}
write_status "deploying" "$current_sha" "$previous_sha" 0
git -C "$APP_DIR" reset --hard "$SHA"
docker compose -f "$COMPOSE_FILE" up -d --build --remove-orphans
for attempt in $(seq 1 30); do
if curl --fail --silent --show-error --max-time 5 "$HEALTHCHECK_URL" >/dev/null; then
printf '%s\n' "$SHA" > "$APP_DIR/.forgeflow-live-sha"
printf '%s\n' "$previous_sha" > "$APP_DIR/.forgeflow-previous-sha"
chmod 0640 "$APP_DIR/.forgeflow-live-sha" "$APP_DIR/.forgeflow-previous-sha"
write_status "healthy" "$SHA" "$previous_sha" 0
trap - ERR
echo "Deployment healthy at $SHA"
exit 0
fi
echo "Healthcheck attempt $attempt/30 did not pass yet"
sleep 2
done
trap - ERR
write_status "unhealthy" "$SHA" "$previous_sha" 70
echo "Healthcheck failed after deployment" >&2
exit 70
+4
View File
@@ -0,0 +1,4 @@
# Validate with: sudo visudo -cf /etc/sudoers.d/forgeflow-runner
# Replace "act_runner" with the account that executes your trusted Gitea runner.
# Do not add shell wildcards or other commands.
act_runner ALL=(root) NOPASSWD: /usr/local/bin/forgeflow-deploy
+9
View File
@@ -0,0 +1,9 @@
# ForgeFlow deployment targets
#
# Format (one target per line, no shell syntax):
# repository|environment|app_dir|branch|compose_file|healthcheck_url|status_file
#
# Keep this file root-owned and not writable by the runner account.
jens/example-app|production|/srv/example-app|main|/srv/example-app/compose.yml|http://127.0.0.1:8080/health|/var/lib/forgeflow-status/example-app-production.json
jens/example-app|staging|/srv/example-app-staging|main|/srv/example-app-staging/compose.yml|http://127.0.0.1:18080/health|/var/lib/forgeflow-status/example-app-staging.json
@@ -0,0 +1,8 @@
# Example virtual-host fragment. Keep this endpoint outside the application
# process so it can still report a failed release when the app itself is down.
location = /.well-known/forgeflow {
default_type application/json;
alias /var/lib/forgeflow-status/example-app-production.json;
add_header Cache-Control "no-store" always;
add_header X-Content-Type-Options "nosniff" always;
}
+11
View File
@@ -0,0 +1,11 @@
{
"repository": "jens/example-app",
"environment": "production",
"request_id": "3a6ed71c-d52d-4d8d-9678-96e0c9456a81",
"commit_sha": "0123456789abcdef0123456789abcdef01234567",
"previous_sha": "89abcdef0123456789abcdef0123456789abcdef",
"requested_sha": "0123456789abcdef0123456789abcdef01234567",
"deployed_at": "2026-07-24T13:00:00Z",
"health": "healthy",
"last_exit_code": 0
}