Files
werkator/legacy/gitTally
T

5968 lines
255 KiB
Bash
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env bash
# A small and opinionated continuous integration tool for projects that do not have the hardware budget or operational staff for large CI/CD systems.
# Made to run locally or in Designed for Hostsharing Container Server environments with Docker (Podman not tested yet).
#
# Create the config file with (--env), edit and source,
# then run the script in the root of a git working tree, e.g. with --install.
# The script waits for new commits on any branch on origin, then checks it out, and runs a command to build+test the branch.
# Call with -h or --help for more details.
#
# This script was mostly vibe-coded to replace the clumsy configuration by code of Jenkins.
#
# MIT License
#
# Copyright (c) 2026 Michael Hönnig
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in all
# copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE.
script_version="0.7.8"
script_path=$(realpath "${BASH_SOURCE[0]}")
script_name=$(basename "${BASH_SOURCE[0]}")
tool_name="GitTally"
installed_script_path=
# Ensure consistent output from system tools (e.g., date, df, awk, sort) across different environments.
export LC_ALL=C
repo_root=
systemd_unit_name="gitTally.service"
monitor_generation=$(date +%s)
default_build_command='./gradlew --console=plain --no-daemon --no-build-cache --rerun-tasks test'
default_build_clean_command='rm -rf build'
default_build_artefact_dirs='build/reports'
default_build_stdout_log='build.stdout.log'
default_build_stderr_log='build.stderr.log'
default_build_docker_image='hsadmin-ng-build-env:latest'
default_build_dockerfile='Jenkins/jenkins-agent/Dockerfile'
default_build_docker_context='Jenkins/jenkins-agent'
default_build_docker_network='host'
default_build_docker_preflight_command='docker version'
default_build_docker_env='TESTCONTAINERS_RYUK_DISABLED=false'
default_build_docker_java_tool_options='-Ddocker.client.strategy=org.testcontainers.dockerclient.UnixSocketClientProviderStrategy -Dtestcontainers.docker.socket.override=/var/run/docker.sock'
default_new_branch_commit_max_age='5d'
default_auto_build_times='02:00'
default_gitea_status_context='GitTally'
default_gitea_base_url='https://git.example.org'
default_gitea_owner='example-owner'
default_gitea_repo='example-repo'
default_gitea_git_username='example-user'
default_artifact_public_base_url='https://ci.example.org/'
default_impressum_url='https://example.org/imprint.html'
default_artifact_nginx_server_name='ci.example.org'
default_artifact_nginx_upstream_host='ci.example.org'
default_artifact_nginx_container_name='gittally-nginx-example-repo'
default_artifact_letsencrypt_email='admin@example.org'
config_file_var() {
local name="$1"
printf 'GITTALLY_CONFIG_%s' "$name"
}
load_repo_config() {
local config_file
local before_vars
local after_vars
local env_name
local config_name
local backup_name
repo_root=$(git rev-parse --show-toplevel 2>/dev/null || true)
if [ -z "$repo_root" ]; then
return 0
fi
config_file="$repo_root/.gitTally"
if [ ! -f "$config_file" ]; then
return 0
fi
before_vars=$(compgen -v GITTALLY_ | sort)
while IFS= read -r env_name; do
if [ -z "$env_name" ]; then
continue
fi
backup_name="__gittally_env_backup_$env_name"
printf -v "$backup_name" '%s' "${!env_name}"
done <<<"$before_vars"
set -a
# shellcheck source=/dev/null
. "$config_file"
set +a
after_vars=$(compgen -v GITTALLY_ | sort)
while IFS= read -r env_name; do
if [ -z "$env_name" ]; then
continue
fi
config_name=$(config_file_var "$env_name")
printf -v "$config_name" '%s' "${!env_name}"
if grep -Fxq "$env_name" <<<"$before_vars"; then
backup_name="__gittally_env_backup_$env_name"
printf -v "$env_name" '%s' "${!backup_name}"
unset "$backup_name"
else
unset "$env_name"
fi
done <<<"$after_vars"
}
config_value() {
local primary_name="$1"
local fallback_name="$2"
local default_value="$3"
local config_name
if [ -n "$primary_name" ] && [ -n "${!primary_name+x}" ]; then
printf '%s' "${!primary_name}"
elif [ -n "$fallback_name" ] && [ -n "${!fallback_name+x}" ]; then
printf '%s' "${!fallback_name}"
elif [ -n "$primary_name" ]; then
config_name=$(config_file_var "$primary_name")
if [ -n "${!config_name+x}" ]; then
printf '%s' "${!config_name}"
else
printf '%s' "$default_value"
fi
else
printf '%s' "$default_value"
fi
}
branch_config_value() {
local primary_name="$1"
local fallback_name="$2"
local checkout_repo_root
local config_file
local value_file
local status
checkout_repo_root=$(git rev-parse --show-toplevel 2>/dev/null) || return 1
config_file="$checkout_repo_root/.gitTally"
if [ ! -f "$config_file" ]; then
return 1
fi
value_file=$(mktemp "${TMPDIR:-/tmp}/gittally-config-value.XXXXXX") || return 1
(
unset "$primary_name"
if [ -n "$fallback_name" ]; then
unset "$fallback_name"
fi
set -a
# shellcheck source=/dev/null
. "$config_file" >/dev/null
set +a
if [ -n "${!primary_name+x}" ]; then
printf '%s' "${!primary_name}" >"$value_file"
elif [ -n "$fallback_name" ] && [ -n "${!fallback_name+x}" ]; then
printf '%s' "${!fallback_name}" >"$value_file"
else
exit 1
fi
)
status=$?
if [ "$status" -eq 0 ]; then
cat "$value_file"
fi
rm -f "$value_file"
return "$status"
}
install_target_dir() {
local target_dir
target_dir=$(config_value GITTALLY_INSTALL_DIR "" "$HOME/bin")
if [ -z "$target_dir" ]; then
echo "ERROR: GITTALLY_INSTALL_DIR must not be empty." >&2
return 1
fi
printf '%s' "$target_dir"
}
systemd_quote() {
local value="$1"
value="${value//\\/\\\\}"
value="${value//\"/\\\"}"
value="${value//%/%%}"
printf '"%s"' "$value"
}
systemd_path() {
local value="$1"
value="${value//%/%%}"
printf '%s' "$value"
}
generate_systemd_config() {
local target_dir="$1"
local target_path="$2"
local service_path="$target_dir/$systemd_unit_name"
local env_path="$target_dir/gitTally.env"
local working_dir="${repo_root:-$(pwd)}"
local service_description="GitTally CI for $(basename "$working_dir")"
cat >"$service_path" <<EOF
[Unit]
Description=$service_description
Wants=network-online.target
After=network-online.target docker.service
[Service]
Type=simple
WorkingDirectory=$(systemd_path "$working_dir")
EnvironmentFile=-$(systemd_path "$env_path")
ExecStart=/usr/bin/env bash $(systemd_quote "$target_path") --nginx --docker
Restart=always
RestartSec=30
[Install]
WantedBy=default.target
EOF
echo "generated $service_path"
if [ -f "$env_path" ]; then
echo "kept existing $env_path"
return 0
fi
cat >"$env_path" <<EOF
# EnvironmentFile for $tool_name systemd service.
# Values here override environment values loaded from the repository .gitTally file.
GITTALLY_GITEA_GIT_USERNAME=$(shell_quote "$gitea_git_username")
GITTALLY_GITEA_TOKEN=
EOF
chmod 600 "$env_path"
echo "generated $env_path"
}
install_systemd_service() {
local service_path="$1"
local user_systemd_dir="$HOME/.config/systemd/user"
local since_ts
mkdir -p "$user_systemd_dir"
ln -sf "$service_path" "$user_systemd_dir/$systemd_unit_name"
systemctl --user daemon-reload
systemctl --user enable "$systemd_unit_name"
since_ts=$(date -Iseconds)
systemctl --user restart "$systemd_unit_name"
echo "installed systemd user service: $systemd_unit_name"
echo "waiting for service to be ready..."
timeout 120 journalctl --user -u "$systemd_unit_name" --since="$since_ts" --follow --output=cat --no-pager 2>/dev/null | \
while IFS= read -r line; do
echo " | $line"
[[ "$line" == *"$tool_name version"*": service ready"* ]] && break
done || true
}
run_systemd_action() {
local action="$1"
case "$action" in
start|stop|status|enable|disable)
systemctl --user "$action" "$systemd_unit_name"
;;
reload)
systemctl --user daemon-reload
systemctl --user restart "$systemd_unit_name"
;;
log)
journalctl --user -u "$systemd_unit_name" -b --no-pager
;;
watch)
journalctl --user -u "$systemd_unit_name" -f
;;
*)
echo "ERROR: unknown systemd action: $action" >&2
echo "Supported actions: start, stop, reload, status, log, watch, enable, disable." >&2
return 1
;;
esac
}
generate_update_script() {
local target_dir="$1"
local target_path="$2"
local install_branch="$3"
local update_script_path="$target_dir/gitTally-update"
local env_path="$target_dir/gitTally.env"
local working_dir="${repo_root:-$(pwd)}"
cat >"$update_script_path" <<EOF
#!/usr/bin/env bash
set -euo pipefail
env_file=$(shell_quote "$env_path")
install_dir=$(shell_quote "$target_dir")
repo_root=$(shell_quote "$working_dir")
install_branch=$(shell_quote "$install_branch")
installed_git_tally=$(shell_quote "$target_path")
if [ -f "\$env_file" ]; then
set -a
# shellcheck source=/dev/null
. "\$env_file"
set +a
fi
export GITTALLY_INSTALL_DIR="\${GITTALLY_INSTALL_DIR:-\$install_dir}"
if [ -z "\$install_branch" ] || [ "\$install_branch" = "detached HEAD" ]; then
echo "ERROR: cannot update $tool_name because the install source branch is not known." >&2
exit 1
fi
cd "\$repo_root"
git switch "\$install_branch"
tools/gitTally --pull --install --systemd
EOF
chmod 700 "$update_script_path"
echo "generated $update_script_path"
}
install_to_bin() {
local target_dir
local target_path
local install_branch
target_dir=$(install_target_dir) || exit 1
mkdir -p "$target_dir"
target_dir=$(realpath "$target_dir")
target_path="$target_dir/$script_name"
cp "$script_path" "$target_path"
chmod +x "$target_path"
install_branch=$(git -C "$(dirname "$script_path")" branch --show-current 2>/dev/null)
if [ -z "$install_branch" ]; then
install_branch="detached HEAD"
fi
generate_systemd_config "$target_dir" "$target_path"
generate_update_script "$target_dir" "$target_path" "$install_branch"
echo "installed $target_path from branch: $install_branch"
installed_script_path="$target_path"
}
shell_quote() {
printf "'%s'" "$(printf '%s' "$1" | sed "s/'/'\\\\''/g")"
}
origin_url() {
git remote get-url origin 2>/dev/null || true
}
detect_git_username_from_origin_url() {
local origin_url
local authority
origin_url=$(origin_url)
authority="${origin_url#*://}"
authority="${authority%%/*}"
if [[ "$authority" == *@* ]]; then
echo "${authority%@*}"
fi
}
detect_gitea_repo_from_origin_url() {
local origin_url
local path
origin_url=$(origin_url)
if [ -z "$origin_url" ]; then
return 0
fi
if [[ "$origin_url" =~ ^https?:// ]]; then
if [ -z "$gitea_base_url" ]; then
gitea_base_url=$(printf '%s' "$origin_url" | sed -E 's#^(https?://)([^/@]+@)?([^/]+)/.*#\1\3#')
fi
path=$(printf '%s' "$origin_url" | sed -E 's#^https?://[^/]+/##')
elif [[ "$origin_url" == git@*:* ]]; then
if [ -z "$gitea_base_url" ]; then
gitea_base_url="https://${origin_url#git@}"
gitea_base_url="${gitea_base_url%%:*}"
fi
path="${origin_url#*:}"
else
return 0
fi
path="${path%.git}"
if [ -z "$gitea_owner" ]; then
gitea_owner="${path%%/*}"
fi
if [ -z "$gitea_repo" ] && [[ "$path" == */* ]]; then
gitea_repo="${path#*/}"
fi
}
print_env_section() {
local title="$1"
printf '\n'
printf '# %s\n' "================================================================================"
printf '# %s\n' "$title"
printf '# %s\n' "--------------------------------------------------------------------------------"
}
is_sensitive_var_name() {
case "$1" in
*_TOKEN*|*_SECRET*|*_PASSWORD*|*_APIKEY*|*_PASSKEY*|*_USERNAME*) return 0 ;;
esac
return 1
}
print_env_var() {
local name="$1"
local value="$2"
local comment="$3"
local default_value="${4:-}"
local display_value="$value"
if is_sensitive_var_name "$name" && [ -n "$value" ]; then
display_value='<secret-value-hidden>'
fi
printf '\n'
printf '# %s\n' "$comment"
printf '# default %s=%s\n' "$name" "$(shell_quote "$default_value")"
printf 'export %s=%s\n' "$name" "$(shell_quote "$display_value")"
}
print_env_optional_var() {
local name="$1"
local value="$2"
local comment="$3"
local default_value="${4:-}"
local display_value="$value"
if is_sensitive_var_name "$name" && [ -n "$value" ]; then
display_value='secret-value-hidden'
fi
printf '\n'
printf '# %s\n' "$comment"
printf '# default %s=%s\n' "$name" "$(shell_quote "$default_value")"
if [ -n "$value" ]; then
printf '# resolved %s=%s\n' "$name" "$(shell_quote "$display_value")"
fi
printf '# export %s=%s\n' "$name" "$(shell_quote "")"
}
print_env() {
local install_dir
local build_command
local build_clean_command
local build_artefact_dirs
local build_stdout_log
local build_stderr_log
local build_docker_image
local build_dockerfile
local build_docker_context
local build_docker_network
local build_docker_preflight_command
local build_docker_env
local build_docker_java_tool_options
local new_branch_commit_max_age
local artifact_server_port
local artifact_server_bind_address
local artifact_public_base_url
local artifact_build_retention_per_branch
local artifact_nginx_server_name
local artifact_nginx_http_port
local artifact_nginx_https_port
local artifact_nginx_upstream_host
local artifact_nginx_container_name
local artifact_nginx_state_dir
local artifact_letsencrypt_email
local impressum_url
local gitea_token
local gitea_status_context
local default_artifact_nginx_state_dir
local default_repository_key
local default_repository_simple_name
install_dir=$(config_value GITTALLY_INSTALL_DIR "" "$HOME/bin")
build_command=$(config_value GITTALLY_BUILD_COMMAND "" "$default_build_command")
build_clean_command=$(config_value GITTALLY_BUILD_CLEAN_COMMAND "" "$default_build_clean_command")
build_artefact_dirs=$(config_value GITTALLY_BUILD_ARTEFACT_DIRS "" "$default_build_artefact_dirs")
build_stdout_log=$(config_value GITTALLY_BUILD_STDOUT_LOG "" "$default_build_stdout_log")
build_stderr_log=$(config_value GITTALLY_BUILD_STDERR_LOG "" "$default_build_stderr_log")
build_docker_image=$(config_value GITTALLY_BUILD_DOCKER_IMAGE HSADMIN_NG_BUILD_IMAGE "$default_build_docker_image")
build_dockerfile=$(config_value GITTALLY_BUILD_DOCKERFILE "" "$default_build_dockerfile")
build_docker_context=$(config_value GITTALLY_BUILD_DOCKER_CONTEXT "" "$default_build_docker_context")
build_docker_network=$(config_value GITTALLY_BUILD_DOCKER_NETWORK HSADMIN_NG_BUILD_NETWORK "$default_build_docker_network")
build_docker_preflight_command=$(config_value GITTALLY_BUILD_DOCKER_PREFLIGHT_COMMAND "" "$default_build_docker_preflight_command")
build_docker_env=$(config_value GITTALLY_BUILD_DOCKER_ENV "" "$default_build_docker_env")
build_docker_java_tool_options=$(config_value GITTALLY_BUILD_DOCKER_JAVA_TOOL_OPTIONS "" "$default_build_docker_java_tool_options")
new_branch_commit_max_age=$(config_value GITTALLY_NEW_BRANCH_COMMIT_MAX_AGE "" "$default_new_branch_commit_max_age")
auto_build_branches=$(config_value GITTALLY_AUTO_BUILD_BRANCHES "" "")
auto_build_times=$(config_value GITTALLY_AUTO_BUILD_TIMES "" "$default_auto_build_times")
artifact_server_port=$(config_value GITTALLY_ARTIFACT_SERVER_PORT HSADMIN_NG_ARTIFACT_SERVER_PORT 18080)
artifact_server_bind_address=$(config_value GITTALLY_ARTIFACT_SERVER_BIND_ADDRESS HSADMIN_NG_ARTIFACT_SERVER_BIND_ADDRESS 0.0.0.0)
artifact_http_server_port="$artifact_server_port"
artifact_http_server_bind_address="$artifact_server_bind_address"
artifact_nginx_server_name=$(config_value GITTALLY_ARTIFACT_NGINX_SERVER_NAME HSADMIN_NG_ARTIFACT_NGINX_SERVER_NAME "")
artifact_public_base_url=$(config_value GITTALLY_ARTIFACT_PUBLIC_BASE_URL HSADMIN_NG_ARTIFACT_PUBLIC_BASE_URL "")
artifact_build_retention_per_branch=$(config_value GITTALLY_ARTIFACT_BUILD_RETENTION_PER_BRANCH HSADMIN_NG_ARTIFACT_BUILD_RETENTION_PER_BRANCH 3)
artifact_nginx_http_port=$(config_value GITTALLY_ARTIFACT_NGINX_HTTP_PORT HSADMIN_NG_ARTIFACT_NGINX_HTTP_PORT 8080)
artifact_nginx_https_port=$(config_value GITTALLY_ARTIFACT_NGINX_HTTPS_PORT HSADMIN_NG_ARTIFACT_NGINX_HTTPS_PORT 8443)
artifact_nginx_upstream_host=$(config_value GITTALLY_ARTIFACT_NGINX_UPSTREAM_HOST HSADMIN_NG_ARTIFACT_NGINX_UPSTREAM_HOST "")
artifact_nginx_container_name=$(config_value GITTALLY_ARTIFACT_NGINX_CONTAINER_NAME HSADMIN_NG_ARTIFACT_NGINX_CONTAINER_NAME "")
artifact_nginx_state_dir=$(config_value GITTALLY_ARTIFACT_NGINX_STATE_DIR HSADMIN_NG_ARTIFACT_NGINX_STATE_DIR "")
artifact_letsencrypt_email=$(config_value GITTALLY_ARTIFACT_LETSENCRYPT_EMAIL HSADMIN_NG_ARTIFACT_LETSENCRYPT_EMAIL "")
impressum_url=$(config_value GITTALLY_IMPRESSUM_URL "" "$default_impressum_url")
if [ -n "$artifact_public_base_url" ]; then
:
elif [ -n "$artifact_nginx_server_name" ]; then
artifact_public_base_url="https://$artifact_nginx_server_name/"
else
artifact_public_base_url="http://$artifact_server_bind_address:$artifact_server_port/"
fi
if [[ "$artifact_public_base_url" != */ ]]; then
artifact_public_base_url="$artifact_public_base_url/"
fi
gitea_base_url=$(config_value GITTALLY_GITEA_BASE_URL HSADMIN_NG_GITEA_BASE_URL "")
gitea_owner=$(config_value GITTALLY_GITEA_OWNER HSADMIN_NG_GITEA_OWNER "")
gitea_repo=$(config_value GITTALLY_GITEA_REPO HSADMIN_NG_GITEA_REPO "")
gitea_git_username=$(config_value GITTALLY_GITEA_GIT_USERNAME HSADMIN_NG_GITEA_GIT_USERNAME "")
gitea_token=$(config_value GITTALLY_GITEA_TOKEN HSADMIN_NG_GITEA_TOKEN "")
gitea_status_context=$(config_value GITTALLY_GITEA_STATUS_CONTEXT HSADMIN_NG_GITEA_STATUS_CONTEXT "$default_gitea_status_context")
detect_gitea_repo_from_origin_url
if [ -z "$gitea_git_username" ]; then
gitea_git_username=$(detect_git_username_from_origin_url)
fi
default_repository_key=$(printf '%s' "${repo_root:-$(git rev-parse --show-toplevel)}" | sed 's#[^[:alnum:]._-]#_#g')
default_repository_simple_name=$(basename "${repo_root:-$(git rev-parse --show-toplevel)}")
default_artifact_nginx_state_dir='${XDG_STATE_HOME:-$HOME/.local/state}/gittally/nginx/<repo-key>'
if [ -z "$artifact_nginx_upstream_host" ] && [ -n "$artifact_nginx_server_name" ]; then
artifact_nginx_upstream_host="$artifact_nginx_server_name"
fi
if [ -z "$artifact_nginx_container_name" ]; then
artifact_nginx_container_name="gittally-nginx-$(printf '%s' "$default_repository_simple_name" | sed 's#[^[:alnum:]_.-]#-#g')"
fi
if [ -z "$artifact_nginx_state_dir" ]; then
artifact_nginx_state_dir="${XDG_STATE_HOME:-$HOME/.local/state}/gittally/nginx/$default_repository_key"
fi
printf '# Environment for %s version %s\n' "$tool_name" "$script_version"
printf '# Save and source this output before starting the script, for example:\n'
printf '# %s --env > .gittally.env\n' "$script_name"
printf '# . .gittally.env\n'
print_env_section "Installation"
print_env_var GITTALLY_INSTALL_DIR "$install_dir" 'Target directory used by --install.' "$HOME/bin"
print_env_section "Build command"
print_env_var GITTALLY_BUILD_COMMAND "$build_command" 'Fallback shell command used if the checked-out branch .gitTally does not define it. The branch name is available as $branch.' "$default_build_command"
print_env_var GITTALLY_BUILD_CLEAN_COMMAND "$build_clean_command" 'Shell command executed before a non-Docker build and before preparing a Docker workspace.' "$default_build_clean_command"
print_env_var GITTALLY_BUILD_ARTEFACT_DIRS "$build_artefact_dirs" "Report directories copied into artifacts. Separate multiple paths with ';'." "$default_build_artefact_dirs"
print_env_var GITTALLY_BUILD_STDOUT_LOG "$build_stdout_log" 'Artifact filename for captured build stdout.' "$default_build_stdout_log"
print_env_var GITTALLY_BUILD_STDERR_LOG "$build_stderr_log" 'Artifact filename for captured build stderr.' "$default_build_stderr_log"
print_env_var GITTALLY_NEW_BRANCH_COMMIT_MAX_AGE "$new_branch_commit_max_age" 'Maximum age for the latest commit on new origin branches. Use h/d suffix.' "$default_new_branch_commit_max_age"
print_env_section "Docker build runtime"
print_env_var GITTALLY_BUILD_DOCKER_IMAGE "$build_docker_image" 'Docker image used when --docker is enabled.' "$default_build_docker_image"
print_env_var GITTALLY_BUILD_DOCKERFILE "$build_dockerfile" 'Dockerfile used to build the image when it does not exist locally.' "$default_build_dockerfile"
print_env_var GITTALLY_BUILD_DOCKER_CONTEXT "$build_docker_context" 'Docker build context used with GITTALLY_BUILD_DOCKERFILE.' "$default_build_docker_context"
print_env_var GITTALLY_BUILD_DOCKER_NETWORK "$build_docker_network" 'Docker network mode for build containers.' "$default_build_docker_network"
print_env_var GITTALLY_BUILD_DOCKER_PREFLIGHT_COMMAND "$build_docker_preflight_command" 'Command run inside the build container to verify Docker access.' "$default_build_docker_preflight_command"
print_env_var GITTALLY_BUILD_DOCKER_ENV "$build_docker_env" 'Additional environment assignments passed to the build container, separated by spaces.' "$default_build_docker_env"
print_env_var GITTALLY_BUILD_DOCKER_JAVA_TOOL_OPTIONS "$build_docker_java_tool_options" 'Java tool options added for Docker and Testcontainers defaults.' "$default_build_docker_java_tool_options"
print_env_section "Artifact server"
print_env_var GITTALLY_ARTIFACT_SERVER_PORT "$artifact_server_port" 'Preferred HTTP port for serving archived build artifacts.' 18080
print_env_var GITTALLY_ARTIFACT_SERVER_BIND_ADDRESS "$artifact_server_bind_address" 'Bind address for the artifact HTTP server.' 0.0.0.0
print_env_var GITTALLY_ARTIFACT_PUBLIC_BASE_URL "$artifact_public_base_url" 'Public base URL used for artifact links and Gitea status target URLs.' "$default_artifact_public_base_url"
print_env_var GITTALLY_ARTIFACT_BUILD_RETENTION_PER_BRANCH "$artifact_build_retention_per_branch" 'Retained builds per branch. Use a count, or h/d suffix for age based retention.' 3
print_env_section "Nginx and certificates"
print_env_var GITTALLY_ARTIFACT_NGINX_SERVER_NAME "$artifact_nginx_server_name" 'Public server name for the nginx and certificate setup.' "$default_artifact_nginx_server_name"
print_env_var GITTALLY_ARTIFACT_NGINX_HTTP_PORT "$artifact_nginx_http_port" 'Host HTTP port published by the nginx container.' 8080
print_env_var GITTALLY_ARTIFACT_NGINX_HTTPS_PORT "$artifact_nginx_https_port" 'Host HTTPS port published by the nginx container.' 8443
print_env_var GITTALLY_ARTIFACT_NGINX_UPSTREAM_HOST "$artifact_nginx_upstream_host" 'Host name nginx uses to reach the artifact HTTP server.' "$default_artifact_nginx_upstream_host"
print_env_var GITTALLY_ARTIFACT_NGINX_CONTAINER_NAME "$artifact_nginx_container_name" 'Docker container name for the nginx reverse proxy.' "$default_artifact_nginx_container_name"
print_env_optional_var GITTALLY_ARTIFACT_NGINX_STATE_DIR "$artifact_nginx_state_dir" 'Persistent state directory for nginx config, logs, and certificate data.' "$default_artifact_nginx_state_dir"
print_env_var GITTALLY_ARTIFACT_LETSENCRYPT_EMAIL "$artifact_letsencrypt_email" 'Email address used when registering Lets Encrypt certificates.' "$default_artifact_letsencrypt_email"
print_env_section "Legal"
print_env_var GITTALLY_IMPRESSUM_URL "$impressum_url" 'URL for the Impressum (Legal Disclosure) link in the footer.' "$default_impressum_url"
print_env_section "Auto builds"
print_env_var GITTALLY_AUTO_BUILD_BRANCHES "$auto_build_branches" "Semicolon-separated list of branches to rebuild automatically. Leave empty to disable auto builds." ""
print_env_var GITTALLY_AUTO_BUILD_TIMES "$auto_build_times" 'Semicolon-separated list of UTC times (HH:MM) at which auto builds are triggered, e.g. 02:00;08:00;14:00;20:00.' "$default_auto_build_times"
print_env_section "Gitea"
print_env_var GITTALLY_GITEA_BASE_URL "$gitea_base_url" 'Base URL of the Gitea instance.' "$default_gitea_base_url"
print_env_var GITTALLY_GITEA_OWNER "$gitea_owner" 'Gitea repository owner.' "$default_gitea_owner"
print_env_var GITTALLY_GITEA_REPO "$gitea_repo" 'Gitea repository name.' "$default_gitea_repo"
print_env_var GITTALLY_GITEA_GIT_USERNAME "$gitea_git_username" 'HTTPS git username used with the Gitea token. (required)' "$default_gitea_git_username"
print_env_var GITTALLY_GITEA_TOKEN "$gitea_token" 'Token used for Gitea commit statuses and HTTPS git authentication. (required)' ""
print_env_var GITTALLY_GITEA_STATUS_CONTEXT "$gitea_status_context" 'Gitea commit status context published by GitTally.' "$default_gitea_status_context"
}
if [ "$1" = "--env" ]; then
load_repo_config
print_env
exit 0
fi
has_arg() {
local wanted="$1"
shift
local arg
for arg in "$@"; do
if [ "$arg" = "$wanted" ]; then
return 0
fi
done
return 1
}
is_repo_safe_command() {
local arg
if has_arg --install "$@" || has_arg --pull "$@" || has_arg --help "$@" || has_arg -h "$@"; then
return 0
fi
for arg in "$@"; do
case "$arg" in
--systemd|--systemd:*)
return 0
;;
esac
done
return 1
}
script_repo_root=$(git -C "$(dirname "$script_path")" rev-parse --show-toplevel 2>/dev/null || true)
if [ -n "$script_repo_root" ] &&
[ "${GITTALLY_BIN_FORWARD:-${HSADMIN_NG_GIT_WATCH_ORIGIN_AND_TEST_BIN_FORWARD:-}}" != true ] &&
! is_repo_safe_command "$@"; then
echo "ERROR: $tool_name must not be started from within its repository." >&2
echo "Only --pull and --install are allowed from within the repository." >&2
echo "Install it first: $script_path --install" >&2
echo "Then start it from: ${GITTALLY_INSTALL_DIR:-$HOME/bin}/$script_name" >&2
exit 1
fi
unset HSADMIN_NG_GIT_WATCH_ORIGIN_AND_TEST_BIN_FORWARD
unset GITTALLY_BIN_FORWARD
reported_skipped_new_branches=$(mktemp "${TMPDIR:-/tmp}/gittally-skipped.XXXXXX")
active_build_branch=
active_build_artifact_key=
active_build_started_at=
active_build_pid=
active_build_cancelled=false
git_askpass_file=
artifact_nginx_container_started=false
artifact_nginx_container_id=
cleanup() {
local ended_at
local ended_timestamp
local build_duration
if [ -n "${active_build_pid:-}" ]; then
echo "stopping active build process: $active_build_pid"
terminate_process_tree "$active_build_pid" TERM
sleep 2
if kill -0 "$active_build_pid" >/dev/null 2>&1; then
terminate_process_tree "$active_build_pid" KILL
fi
wait "$active_build_pid" 2>/dev/null || true
active_build_pid=
cleanup_stale_build_runtime || true
fi
clear_build_cancel_request || true
if [ -n "${active_build_branch:-}" ]; then
ended_at=$(date +%s)
ended_timestamp=$(date -Iseconds)
build_duration=$(format_build_duration "$((ended_at - active_build_started_at))")
echo "marking interrupted build: $active_build_branch"
record_build_result "$active_build_branch" interrupted "$build_duration" "$ended_timestamp" "$active_build_artifact_key" || true
write_current_build_page "$active_build_branch" interrupted "$ended_timestamp" || true
active_build_branch=
active_build_artifact_key=
active_build_started_at=
fi
if [ -n "$git_askpass_file" ]; then
rm -f "$git_askpass_file"
fi
rm -f "$reported_skipped_new_branches"
if [ -n "$artifact_http_server_pid" ]; then
kill "$artifact_http_server_pid" >/dev/null 2>&1 || true
wait "$artifact_http_server_pid" 2>/dev/null || true
fi
if [ "${artifact_nginx_container_started:-false}" = true ] && [ -n "${artifact_nginx_container_id:-}" ]; then
docker rm -f "$artifact_nginx_container_id" >/dev/null 2>&1 || true
artifact_nginx_container_started=false
artifact_nginx_container_id=
fi
}
trap cleanup EXIT
trap 'exit 130' INT
trap 'exit 143' TERM
trap 'exit 129' HUP
. .aliases
load_repo_config
use_docker_build=false
use_artifact_http_server=false
use_artifact_nginx=false
open_artifact_frontend=false
pull_current_branch=false
install_after_pull=false
install_systemd_after_install=false
systemd_command_given=false
systemd_action=
retry_failed_builds_requested=false
stay_on_current_branch=false
environment_build_command=${GITTALLY_BUILD_COMMAND-"$default_build_command"}
build_command=$(config_value GITTALLY_BUILD_COMMAND "" "$default_build_command")
build_clean_command=$(config_value GITTALLY_BUILD_CLEAN_COMMAND "" "$default_build_clean_command")
build_artefact_dirs=$(config_value GITTALLY_BUILD_ARTEFACT_DIRS "" "$default_build_artefact_dirs")
build_stdout_log=$(config_value GITTALLY_BUILD_STDOUT_LOG "" "$default_build_stdout_log")
build_stderr_log=$(config_value GITTALLY_BUILD_STDERR_LOG "" "$default_build_stderr_log")
new_branch_commit_max_age=$(config_value GITTALLY_NEW_BRANCH_COMMIT_MAX_AGE "" "$default_new_branch_commit_max_age")
auto_build_branches=$(config_value GITTALLY_AUTO_BUILD_BRANCHES "" "")
auto_build_times=$(config_value GITTALLY_AUTO_BUILD_TIMES "" "$default_auto_build_times")
docker_build_image=$(config_value GITTALLY_BUILD_DOCKER_IMAGE HSADMIN_NG_BUILD_IMAGE "$default_build_docker_image")
docker_build_dockerfile=$(config_value GITTALLY_BUILD_DOCKERFILE "" "$default_build_dockerfile")
docker_build_context=$(config_value GITTALLY_BUILD_DOCKER_CONTEXT "" "$default_build_docker_context")
docker_build_network=$(config_value GITTALLY_BUILD_DOCKER_NETWORK HSADMIN_NG_BUILD_NETWORK "$default_build_docker_network")
docker_build_preflight_command=$(config_value GITTALLY_BUILD_DOCKER_PREFLIGHT_COMMAND "" "$default_build_docker_preflight_command")
docker_build_env=$(config_value GITTALLY_BUILD_DOCKER_ENV "" "$default_build_docker_env")
docker_build_java_tool_options=$(config_value GITTALLY_BUILD_DOCKER_JAVA_TOOL_OPTIONS "" "$default_build_docker_java_tool_options")
bootstrap_docker_build_image="$docker_build_image"
bootstrap_docker_build_dockerfile="$docker_build_dockerfile"
bootstrap_docker_build_context="$docker_build_context"
bootstrap_docker_build_network="$docker_build_network"
bootstrap_docker_build_preflight_command="$docker_build_preflight_command"
bootstrap_docker_build_env="$docker_build_env"
bootstrap_docker_build_java_tool_options="$docker_build_java_tool_options"
artifact_http_server_port=$(config_value GITTALLY_ARTIFACT_SERVER_PORT HSADMIN_NG_ARTIFACT_SERVER_PORT 18080)
artifact_http_server_bind_address=$(config_value GITTALLY_ARTIFACT_SERVER_BIND_ADDRESS HSADMIN_NG_ARTIFACT_SERVER_BIND_ADDRESS 0.0.0.0)
artifact_public_base_url=$(config_value GITTALLY_ARTIFACT_PUBLIC_BASE_URL HSADMIN_NG_ARTIFACT_PUBLIC_BASE_URL "")
artifact_build_retention_per_branch=$(config_value GITTALLY_ARTIFACT_BUILD_RETENTION_PER_BRANCH HSADMIN_NG_ARTIFACT_BUILD_RETENTION_PER_BRANCH 3)
artifact_nginx_server_name=$(config_value GITTALLY_ARTIFACT_NGINX_SERVER_NAME HSADMIN_NG_ARTIFACT_NGINX_SERVER_NAME "")
artifact_nginx_http_port=$(config_value GITTALLY_ARTIFACT_NGINX_HTTP_PORT HSADMIN_NG_ARTIFACT_NGINX_HTTP_PORT 8080)
artifact_nginx_https_port=$(config_value GITTALLY_ARTIFACT_NGINX_HTTPS_PORT HSADMIN_NG_ARTIFACT_NGINX_HTTPS_PORT 8443)
artifact_nginx_upstream_host=$(config_value GITTALLY_ARTIFACT_NGINX_UPSTREAM_HOST HSADMIN_NG_ARTIFACT_NGINX_UPSTREAM_HOST "")
artifact_nginx_container_name=$(config_value GITTALLY_ARTIFACT_NGINX_CONTAINER_NAME HSADMIN_NG_ARTIFACT_NGINX_CONTAINER_NAME "")
artifact_nginx_state_dir=$(config_value GITTALLY_ARTIFACT_NGINX_STATE_DIR HSADMIN_NG_ARTIFACT_NGINX_STATE_DIR "")
artifact_letsencrypt_email=$(config_value GITTALLY_ARTIFACT_LETSENCRYPT_EMAIL HSADMIN_NG_ARTIFACT_LETSENCRYPT_EMAIL "")
impressum_url=$(config_value GITTALLY_IMPRESSUM_URL "" "$default_impressum_url")
artifact_http_server_pid=
artifact_http_server_local_url=
artifact_http_server_url=
gitea_base_url=$(config_value GITTALLY_GITEA_BASE_URL HSADMIN_NG_GITEA_BASE_URL "")
gitea_owner=$(config_value GITTALLY_GITEA_OWNER HSADMIN_NG_GITEA_OWNER "")
gitea_repo=$(config_value GITTALLY_GITEA_REPO HSADMIN_NG_GITEA_REPO "")
gitea_git_username=$(config_value GITTALLY_GITEA_GIT_USERNAME HSADMIN_NG_GITEA_GIT_USERNAME "")
gitea_token=$(config_value GITTALLY_GITEA_TOKEN HSADMIN_NG_GITEA_TOKEN "")
gitea_status_context=$(config_value GITTALLY_GITEA_STATUS_CONTEXT HSADMIN_NG_GITEA_STATUS_CONTEXT "$default_gitea_status_context")
branches_to_build=()
usage() {
echo "$tool_name - a simple Gitea branch CI tally."
echo "Checks for branches on origin, pulls, builds, and records their states, logs, reports, and artifacts."
echo "Usage: $0 [--install] [--systemd] [--env] [--pull] [--docker] [--http] [--nginx] [--retry] [--stay] [--open] [branch ...]"
echo
echo "With --install, this script and systemd config files are installed to GITTALLY_INSTALL_DIR,"
echo " defaulting to the current user's ~/bin directory."
echo " It also installs gitTally-update to pull, reinstall, restart, and watch the service."
echo "With --systemd, --install also installs/reloads the generated systemd user service."
echo "Systemd actions: --systemd:start, --systemd:stop, --systemd:reload, --systemd:status,"
echo " --systemd:log, --systemd:watch, --systemd:enable, --systemd:disable."
echo "With --env, supported environment variables with detected defaults are printed."
echo "With --pull, the current branch is pulled from origin using the configured Gitea token."
echo "If a .gitTally file exists in the repository root, its non-secret config values are loaded."
echo "Shell environment variables override values from .gitTally."
echo "When combined, --pull runs before --install."
echo "Existing local branches and recent new origin branches are watched."
echo "New origin branches are watched if they do not exist locally yet"
echo " and whose latest origin commit is not older than GITTALLY_NEW_BRANCH_COMMIT_MAX_AGE."
echo "With --docker, the build command runs in the configured Docker image."
echo "With --http, an HTTP server for archived build artifacts is started;"
echo " failed builds continue without asking to open the artifact index."
echo "With --nginx, --http is implied and a Docker nginx reverse proxy with Let's Encrypt is configured and started."
echo "With --open, --http is implied and the frontend is opened in the local browser once the server is running."
echo "With --retry, branches whose latest build failed are built again even without new commits."
echo "With --stay, only the currently checked-out branch is built; other branches stay pending."
echo
echo "Set GITTALLY_BUILD_COMMAND as fallback build command if the checked-out branch .gitTally does not define it;"
echo " branch is exported for shell expansion."
echo " Default: $default_build_command"
echo "Set GITTALLY_BUILD_CLEAN_COMMAND to override the pre-build clean command; default: $default_build_clean_command"
echo "Set GITTALLY_BUILD_ARTEFACT_DIRS to override report directories copied into artifacts; separate paths with ';'."
echo "Set GITTALLY_BUILD_STDOUT_LOG and GITTALLY_BUILD_STDERR_LOG to override persisted build log names."
echo "Set GITTALLY_BUILD_DOCKER_IMAGE to override the Docker image name."
echo "Set GITTALLY_BUILD_DOCKERFILE and GITTALLY_BUILD_DOCKER_CONTEXT to override image build inputs."
echo "Set GITTALLY_BUILD_DOCKER_NETWORK to override the Docker network mode; default: host."
echo "Set GITTALLY_BUILD_DOCKER_PREFLIGHT_COMMAND to override the container Docker access check."
echo "Set GITTALLY_BUILD_DOCKER_ENV for additional Docker build-container env assignments, separated by spaces."
echo "Set GITTALLY_BUILD_DOCKER_JAVA_TOOL_OPTIONS to override Testcontainers Java defaults."
echo "Set GITTALLY_NEW_BRANCH_COMMIT_MAX_AGE to override how long new origin branches are considered recent;"
echo " use a value ending in h/d; default: $default_new_branch_commit_max_age."
echo "Legacy HSADMIN_NG_BUILD_IMAGE and HSADMIN_NG_BUILD_NETWORK are still accepted as fallbacks."
echo
echo "Set GITTALLY_ARTIFACT_SERVER_PORT to override the preferred artifact server port; default: 18080."
echo "Set GITTALLY_ARTIFACT_SERVER_BIND_ADDRESS to override the artifact server bind address; default: 0.0.0.0."
echo "Set GITTALLY_ARTIFACT_PUBLIC_BASE_URL to override public artifact URLs, for example behind nginx."
echo "Set GITTALLY_ARTIFACT_BUILD_RETENTION_PER_BRANCH to override retained builds per branch;"
echo " use a number for count, or a value ending in h/d for age; default: 3."
echo "Set GITTALLY_ARTIFACT_NGINX_SERVER_NAME to configure the nginx/Let's Encrypt hostname."
echo "Set GITTALLY_ARTIFACT_NGINX_HTTP_PORT and GITTALLY_ARTIFACT_NGINX_HTTPS_PORT to override nginx host ports; defaults: 8080/8443."
echo "Set GITTALLY_ARTIFACT_NGINX_UPSTREAM_HOST to override the host nginx uses for the artifact HTTP server."
echo "Set GITTALLY_ARTIFACT_NGINX_CONTAINER_NAME to override the Docker container name."
echo "Set GITTALLY_ARTIFACT_NGINX_STATE_DIR to override the persistent nginx/certbot state directory."
echo "Set GITTALLY_ARTIFACT_LETSENCRYPT_EMAIL to register the certificate with an email address."
echo "Legacy HSADMIN_NG_ARTIFACT_* variables are still accepted as fallbacks."
echo
echo "Set GITTALLY_GITEA_TOKEN to publish/read build statuses and authenticate HTTPS git commands via Gitea; required for startup."
echo "Set GITTALLY_GITEA_GIT_USERNAME to authenticate HTTPS git commands with GITTALLY_GITEA_TOKEN; required for startup."
echo "Set GITTALLY_GITEA_BASE_URL, GITTALLY_GITEA_OWNER, and GITTALLY_GITEA_REPO to override origin-based detection."
echo "Set GITTALLY_GITEA_STATUS_CONTEXT to override the status context; default: GitTally."
echo "Legacy HSADMIN_NG_GITEA_* variables are still accepted as fallbacks."
echo
echo "Branch arguments may be full names or unique name parts matching local or origin branches."
echo "If multiple branches match, the script asks which one to use."
}
normalize_branch_name() {
local branch="$1"
branch="${branch#"${branch%%[![:space:]]*}"}"
branch="${branch%"${branch##*[![:space:]]}"}"
branch="${branch#\* }"
branch="${branch#remotes/}"
branch="${branch#remote/}"
branch="${branch#origin/}"
echo "$branch"
}
branch_candidates() {
{
git for-each-ref --format='%(refname:strip=2)' refs/heads
git for-each-ref --format='%(refname:strip=3)' refs/remotes/origin | grep -v '^HEAD$'
} | awk '!seen[$0]++'
}
resolve_branch_name() {
local branch_part="$1"
local choice
local index
local selected
local matches=()
mapfile -t matches < <(branch_candidates | grep -F -- "$branch_part")
if [ "${#matches[@]}" -eq 0 ]; then
echo "ERROR: no local or origin branch matches '$branch_part'." >&2
return 1
fi
if [ "${#matches[@]}" -eq 1 ]; then
echo "${matches[0]}"
return 0
fi
echo "Multiple branches match '$branch_part':" >&2
index=1
for selected in "${matches[@]}"; do
echo " $index) $selected" >&2
index=$((index + 1))
done
while true; do
echo -n "Select branch [1-${#matches[@]}]: " >&2
if ! read -r choice; then
echo "ERROR: no branch selected." >&2
return 1
fi
if [[ "$choice" =~ ^[0-9]+$ ]] && [ "$choice" -ge 1 ] && [ "$choice" -le "${#matches[@]}" ]; then
echo "${matches[$((choice - 1))]}"
return 0
fi
echo "ERROR: invalid selection: $choice" >&2
done
}
switch_to_branch() {
local branch="$1"
echo "checking out branch: $branch"
if git show-ref --quiet --verify "refs/heads/$branch"; then
echo "Branch $branch already exists. Checking it out."
git switch "$branch" || return 1
else
echo "Creating and checking out new branch: $branch"
git switch --track -c "$branch" "refs/remotes/origin/$branch" || return 1
fi
}
pull_branch_if_possible() {
local branch="$1"
if git show-ref --quiet --verify "refs/remotes/origin/$branch"; then
git_with_gitea_token fetch origin "$branch" || return 1
git reset --hard "origin/$branch" || return 1
fi
}
pull_current_branch_from_origin() {
local branch
branch=$(git branch --show-current)
if [ -z "$branch" ]; then
echo "ERROR: --pull requires a checked out branch, but HEAD is detached." >&2
return 1
fi
echo "pulling current branch from origin: $branch"
git_with_gitea_token fetch origin "$branch" || return 1
git reset --hard "origin/$branch"
}
branch_exists_on_origin() {
local branch="$1"
git show-ref --quiet --verify "refs/remotes/origin/$branch"
}
branch_matches_current_worktree_branch() {
local branch="$1"
local current_branch
if [ "$stay_on_current_branch" != true ]; then
return 0
fi
current_branch=$(git branch --show-current) || return 1
[ -n "$current_branch" ] && [ "$branch" = "$current_branch" ]
}
validate_stay_on_current_branch() {
local current_branch
if [ "$stay_on_current_branch" != true ]; then
return 0
fi
current_branch=$(git branch --show-current) || return 1
if [ -z "$current_branch" ]; then
echo "ERROR: --stay requires a checked out branch, but HEAD is detached." >&2
return 1
fi
echo "staying on current branch: $current_branch"
}
checkout_and_build() {
local branch="$1"
if ! branch_exists_on_origin "$branch"; then
echo "Branch $branch no longer exists on origin. Skipping build."
return 0
fi
if branch_matches_current_worktree_branch "$branch"; then
if [ "$stay_on_current_branch" = true ]; then
echo "staying on current branch: $branch"
else
switch_to_branch "$branch" || return 1
fi
else
echo "Branch $branch is pending, but --stay keeps this worktree on the current branch."
return 0
fi
pull_branch_if_possible "$branch" || return 1
build_current_checkout
}
branch_has_new_commits() {
local branch="$1"
local upstream
if ! git show-ref --quiet --verify "refs/heads/$branch"; then
git show-ref --quiet --verify "refs/remotes/origin/$branch"
return
fi
upstream=$(git for-each-ref --format='%(upstream)' "refs/heads/$branch") || return 2
if [ -n "$upstream" ]; then
has_new_commits "refs/heads/$branch" "$upstream"
elif git show-ref --quiet --verify "refs/remotes/origin/$branch"; then
has_new_commits "refs/heads/$branch" "refs/remotes/origin/$branch"
else
return 1
fi
}
checkout_requested_branch() {
local branch_arg="$1"
local branch
local branch_has_new_commits_status
local has_remote_updates=false
branch=$(resolve_branch_name "$branch_arg") || return 1
if ! branch_exists_on_origin "$branch"; then
echo "Branch $branch no longer exists on origin. Skipping build."
return 0
fi
while true; do
if branch_has_new_commits "$branch"; then
has_remote_updates=true
break
fi
branch_has_new_commits_status=$?
if [ "$branch_has_new_commits_status" -eq 1 ]; then
break
fi
echo "checking branch $branch for new commits failed; retrying in 10s ..." >&2
sleep 10
retry_fetch_origin
done
if branch_matches_current_worktree_branch "$branch"; then
if [ "$stay_on_current_branch" = true ]; then
echo "staying on current branch: $branch"
else
switch_to_branch "$branch" || return 1
fi
else
echo "Branch $branch is pending, but --stay keeps this worktree on the current branch."
return 0
fi
if [ "$has_remote_updates" = true ]; then
pull_branch_if_possible "$branch" || return 1
fi
if [ "$has_remote_updates" = true ]; then
build_current_checkout
elif branch_has_restartable_build "$branch"; then
echo "Restarting pending, interrupted, or stale running build: $branch"
build_current_checkout
elif [ "$retry_failed_builds_requested" = true ] && branch_has_failed_build "$branch"; then
echo "Retrying failed build: $branch"
build_current_checkout
else
echo "Branch $branch has no new commits. Skipping initial build."
fi
}
print_build_banner() {
local title="$1"
echo
printf '%*s\n' 80 '' | tr ' ' '='
echo "$title"
printf '%*s\n' 80 '' | tr ' ' '-'
}
open_in_local_browser() {
local label="$1"
local target="$2"
if command -v xdg-open >/dev/null 2>&1; then
xdg-open "$target" >/dev/null 2>&1 &
elif command -v open >/dev/null 2>&1; then
open "$target" >/dev/null 2>&1 &
elif command -v sensible-browser >/dev/null 2>&1; then
sensible-browser "$target" >/dev/null 2>&1 &
else
echo "Cannot open $label automatically: no browser opener found."
echo "$label: $target"
return 0
fi
echo "Opened $label: $target"
}
open_artifact_index() {
local branch="$1"
local artifact_key="${2:-}"
local artifact_dir
local artifact_index
local artifact_content_index
local artifact_url
local artifact_target
local artifact_display_target
if [ -z "$artifact_key" ]; then
artifact_key=$(build_artifact_branch_key "$branch")
fi
artifact_dir=$(build_artifact_dir "$branch" "$artifact_key")
artifact_index="$artifact_dir/index.html"
artifact_content_index=$(build_artifact_index_content_file "$artifact_dir")
if [ ! -f "$artifact_index" ] && [ ! -f "$artifact_content_index" ]; then
echo "Artifact index not found: $artifact_index"
return 0
fi
if [ -n "$artifact_http_server_url" ]; then
artifact_url="${artifact_http_server_url}branches/$artifact_key/index.html"
artifact_target="$artifact_url"
artifact_display_target="$artifact_url"
elif [ -f "$artifact_index" ]; then
artifact_target="$artifact_index"
artifact_display_target="file://$artifact_target"
else
artifact_target="$artifact_content_index"
artifact_display_target="file://$artifact_target"
fi
open_in_local_browser "artifact index" "$artifact_display_target"
}
handle_build_failure_prompt() {
local branch="$1"
local artifact_key="${2:-}"
local choice
if [ "$use_artifact_http_server" = true ]; then
return 0
fi
while true; do
echo
printf "Build failed on branch: %s\n[o]pen artifact index, [ENTER/c]ontinue, e[x]it: " "$branch"
if ! IFS= read -r -n 1 choice; then
echo
return 0
fi
echo
case "$choice" in
o|O)
open_artifact_index "$branch" "$artifact_key"
return 0
;;
""|c|C)
return 0
;;
x|X|q|Q)
echo "Exiting."
exit 1
;;
*)
echo "Please press o, c, or x."
;;
esac
done
}
build_results_file() {
git rev-parse --git-path git-watch-origin-and-test/build-results.tsv
}
auto_builds_state_file() {
git rev-parse --git-path git-watch-origin-and-test/auto-builds.tsv
}
build_lock_file() {
git rev-parse --git-path git-watch-origin-and-test/build.lock
}
build_cancel_request_file() {
git rev-parse --git-path git-watch-origin-and-test/cancel-request 2>/dev/null
}
build_cancel_token_file() {
git rev-parse --git-path git-watch-origin-and-test/cancel-token 2>/dev/null
}
build_cancel_accepted_file() {
git rev-parse --git-path git-watch-origin-and-test/cancel-accepted 2>/dev/null
}
new_build_cancel_token() {
if command -v openssl >/dev/null 2>&1; then
openssl rand -hex 24
elif [ -r /proc/sys/kernel/random/uuid ]; then
sed -n '1p' /proc/sys/kernel/random/uuid
else
printf '%s-%s-%s' "$$" "${RANDOM:-0}" "$(date +%s)"
fi
}
write_build_cancel_token() {
local token_file
local token_dir
token_file=$(build_cancel_token_file)
token_dir=$(dirname "$token_file")
mkdir -p "$token_dir" || return 1
new_build_cancel_token >"$token_file"
chmod 600 "$token_file" 2>/dev/null || true
}
read_build_cancel_token() {
local token_file
token_file=$(build_cancel_token_file)
if [ -r "$token_file" ]; then
sed -n '1p' "$token_file"
fi
}
clear_build_cancel_request() {
local request_file
local token_file
local accepted_file
request_file=$(build_cancel_request_file)
token_file=$(build_cancel_token_file)
accepted_file=$(build_cancel_accepted_file)
if [ -n "$request_file" ]; then
rm -f "$request_file"
fi
if [ -n "$token_file" ]; then
rm -f "$token_file"
fi
if [ -n "$accepted_file" ]; then
rm -f "$accepted_file"
fi
}
build_cancel_requested() {
[ -f "$(build_cancel_request_file)" ]
}
process_is_zombie() {
local pid="$1"
local state
state=$(ps -p "$pid" -o stat= 2>/dev/null || true)
[[ "$state" == Z* ]]
}
monitor_build_cancel_request() {
local pid="$1"
local accepted_file="$2"
while kill -0 "$pid" >/dev/null 2>&1; do
if build_cancel_requested; then
if process_is_zombie "$pid"; then
return 0
fi
echo cancel >"$accepted_file"
echo "build cancellation requested"
terminate_process_tree "$pid" TERM
sleep 2
if kill -0 "$pid" >/dev/null 2>&1; then
terminate_process_tree "$pid" KILL
fi
return 130
fi
sleep 1
done
}
wait_for_active_build() {
local pid="$1"
local monitor_pid=
local accepted_file
local exit_code
active_build_cancelled=false
accepted_file=$(build_cancel_accepted_file)
if [ -n "$accepted_file" ]; then
rm -f "$accepted_file"
monitor_build_cancel_request "$pid" "$accepted_file" &
monitor_pid=$!
fi
wait "$pid"
exit_code=$?
if [ -n "$monitor_pid" ]; then
kill "$monitor_pid" >/dev/null 2>&1 || true
wait "$monitor_pid" 2>/dev/null || true
fi
if [ -n "$accepted_file" ] && [ -f "$accepted_file" ]; then
active_build_cancelled=true
return 130
fi
return "$exit_code"
}
process_command_line() {
local pid="$1"
if [ -r "/proc/$pid/cmdline" ]; then
tr '\0' ' ' <"/proc/$pid/cmdline" | sed 's/[[:space:]]*$//'
else
ps -p "$pid" -o args= 2>/dev/null || true
fi
}
process_working_directory() {
local pid="$1"
readlink -f "/proc/$pid/cwd" 2>/dev/null || true
}
process_is_current_shell() {
local pid="$1"
[ "$pid" = "$$" ] || [ "$pid" = "${BASHPID:-}" ] || [ "$pid" = "${PPID:-}" ]
}
terminate_process_tree() {
local pid="$1"
local signal="${2:-TERM}"
local child_pid
if ! kill -0 "$pid" >/dev/null 2>&1; then
return 0
fi
if command -v pgrep >/dev/null 2>&1; then
while IFS= read -r child_pid; do
if [ -n "$child_pid" ]; then
terminate_process_tree "$child_pid" "$signal"
fi
done < <(pgrep -P "$pid" 2>/dev/null || true)
fi
kill "-$signal" "$pid" >/dev/null 2>&1 || true
}
build_lock_holder_pids() {
local lock_path="$1"
if command -v fuser >/dev/null 2>&1; then
fuser "$lock_path" 2>/dev/null | tr -cs '0-9' '\n' | sed '/^$/d' | sort -u
elif command -v lsof >/dev/null 2>&1; then
lsof -t -- "$lock_path" 2>/dev/null | sort -u
fi
}
terminate_stale_build_lock_holders() {
local lock_path="$1"
local repo_dir
local pid
local process_dir
local process_command
local -a stale_pids=()
repo_dir=$(realpath "$PWD")
while IFS= read -r pid; do
if [ -z "$pid" ] || process_is_current_shell "$pid"; then
continue
fi
process_dir=$(process_working_directory "$pid")
process_command=$(process_command_line "$pid")
if [ "$process_dir" = "$repo_dir" ]; then
echo "Terminating stale build lock holder: pid $pid ($process_command)"
stale_pids+=("$pid")
else
echo "Build lock is held by pid $pid outside this repository: $process_command" >&2
fi
done < <(build_lock_holder_pids "$lock_path")
if [ "${#stale_pids[@]}" -eq 0 ]; then
return 1
fi
for pid in "${stale_pids[@]}"; do
terminate_process_tree "$pid" TERM
done
sleep 2
for pid in "${stale_pids[@]}"; do
if kill -0 "$pid" >/dev/null 2>&1; then
echo "Force killing stale build lock holder: pid $pid"
terminate_process_tree "$pid" KILL
fi
done
}
build_artifacts_root() {
echo "${TMPDIR:-/tmp}/git-watch-origin-and-test/$(repository_key)"
}
repository_simple_name() {
basename "${repo_root:-$(git rev-parse --show-toplevel)}"
}
repository_key() {
local current_repo_root
current_repo_root="${repo_root:-$(git rev-parse --show-toplevel)}"
printf '%s' "$current_repo_root" | sed 's#[^[:alnum:]._-]#_#g'
}
build_artifact_branch_key() {
local branch="$1"
local branch_key
local branch_hash
branch_key=$(printf '%s' "$branch" | sed 's#[^[:alnum:]._-]#_#g')
branch_hash=$(printf '%s' "$branch" | sha256sum | awk '{print substr($1, 1, 12)}')
echo "$branch_key-$branch_hash"
}
build_artifact_key() {
local branch="$1"
local timestamp="$2"
local timestamp_key
local build_hash
timestamp_key=$(printf '%s' "$timestamp" | sed 's#[^[:alnum:]._-]#_#g')
build_hash=$(printf '%s\t%s' "$branch" "$timestamp" | sha256sum | awk '{print substr($1, 1, 12)}')
echo "$(build_artifact_branch_key "$branch")-$timestamp_key-$build_hash"
}
build_artifact_dir() {
local branch="$1"
local artifact_key="${2:-}"
if [ -z "$artifact_key" ]; then
artifact_key=$(build_artifact_branch_key "$branch")
fi
echo "$(build_artifacts_root)/branches/$artifact_key"
}
build_artifact_index_content_file() {
local artifact_dir="$1"
echo "$artifact_dir/artifact-index-content.html"
}
format_build_duration() {
local duration_seconds="$1"
printf '%02d:%02d' "$((duration_seconds / 60))" "$((duration_seconds % 60))"
}
normalize_build_result_fields() {
if [ -z "$artifact_key" ] && [ -n "$duration" ] && ! [[ "$duration" =~ ^[0-9]+:[0-5][0-9]$ ]]; then
artifact_key="$duration"
duration=
fi
if [ -n "$duration" ] && ! [[ "$duration" =~ ^[0-9]+:[0-5][0-9]$ ]]; then
duration=
fi
case "$status" in
passed)
status=success
;;
esac
if [ -z "$artifact_key" ]; then
artifact_key=$(build_artifact_branch_key "$branch")
fi
}
display_build_timestamp() {
local timestamp="$1"
echo "${timestamp/T/ }"
}
commit_timestamp() {
local commit="$1"
if [[ "$commit" =~ ^[0-9a-fA-F]{7,40}$ ]] && git cat-file -e "$commit^{commit}" 2>/dev/null; then
git show -s --format=%cI "$commit" 2>/dev/null || true
fi
}
normalize_base_url() {
local base_url="$1"
if [ -z "$base_url" ]; then
return 0
fi
if [[ "$base_url" == */ ]]; then
echo "$base_url"
else
echo "$base_url/"
fi
}
base_url_host() {
local base_url="$1"
if [[ "$base_url" =~ ^[a-zA-Z][a-zA-Z0-9+.-]*:// ]]; then
printf '%s' "$base_url" | sed -E 's#^[a-zA-Z][a-zA-Z0-9+.-]*://([^/@:]+@)?([^/:]+).*$#\2#'
fi
}
safe_container_name_part() {
printf '%s' "$1" | sed 's#[^[:alnum:]_.-]#-#g'
}
gittally_docker_label_args() {
local role="$1"
printf '%s\n' \
--label "org.hostsharing.gittally=true" \
--label "org.hostsharing.gittally.repository=$(repository_key)" \
--label "org.hostsharing.gittally.role=$role"
}
artifact_nginx_default_state_dir() {
echo "${XDG_STATE_HOME:-$HOME/.local/state}/gittally/nginx/$(repository_key)"
}
configure_artifact_nginx_defaults() {
local public_base_url_host
if [ "$use_artifact_nginx" != true ]; then
return 0
fi
use_artifact_http_server=true
if [ -z "$artifact_nginx_server_name" ]; then
public_base_url_host=$(base_url_host "$artifact_public_base_url")
if [ -n "$public_base_url_host" ]; then
artifact_nginx_server_name="$public_base_url_host"
fi
fi
if [ -z "$artifact_public_base_url" ] && [ -n "$artifact_nginx_server_name" ]; then
artifact_public_base_url="https://$artifact_nginx_server_name/"
fi
if [ "$artifact_http_server_port" = "$artifact_nginx_http_port" ] ||
[ "$artifact_http_server_port" = "$artifact_nginx_https_port" ]; then
echo "WARNING: moving artifact HTTP server from port $artifact_http_server_port to 18080 because nginx uses port $artifact_http_server_port." >&2
artifact_http_server_port=18080
if [ "$artifact_http_server_port" = "$artifact_nginx_http_port" ] ||
[ "$artifact_http_server_port" = "$artifact_nginx_https_port" ]; then
artifact_http_server_port=18081
fi
fi
if [ -z "$artifact_nginx_upstream_host" ]; then
artifact_nginx_upstream_host="$artifact_nginx_server_name"
fi
if [ -z "$artifact_nginx_container_name" ]; then
artifact_nginx_container_name="gittally-nginx-$(safe_container_name_part "$(repository_simple_name)")"
fi
if [ -z "$artifact_nginx_state_dir" ]; then
artifact_nginx_state_dir=$(artifact_nginx_default_state_dir)
fi
}
validate_artifact_build_retention_per_branch() {
if ! [[ "$artifact_build_retention_per_branch" =~ ^[1-9][0-9]*([dh])?$ ]]; then
echo "WARNING: invalid GITTALLY_ARTIFACT_BUILD_RETENTION_PER_BRANCH: $artifact_build_retention_per_branch; using 3." >&2
artifact_build_retention_per_branch=3
fi
}
validate_new_branch_commit_max_age() {
if ! [[ "$new_branch_commit_max_age" =~ ^[1-9][0-9]*[dh]$ ]]; then
echo "WARNING: invalid GITTALLY_NEW_BRANCH_COMMIT_MAX_AGE: $new_branch_commit_max_age; using $default_new_branch_commit_max_age." >&2
new_branch_commit_max_age="$default_new_branch_commit_max_age"
fi
}
validate_auto_build_times() {
[ -n "$auto_build_branches" ] || return 0
local valid_times="" IFS=';' slot
for slot in $auto_build_times; do
if [[ "$slot" =~ ^[0-2][0-9]:[0-5][0-9]$ ]]; then
valid_times="${valid_times:+$valid_times;}$slot"
else
echo "WARNING: invalid time in GITTALLY_AUTO_BUILD_TIMES: '$slot'; expected HH:MM (semicolon-separated)." >&2
fi
done
if [ -z "$valid_times" ] && [ -n "$auto_build_times" ]; then
echo "WARNING: no valid times in GITTALLY_AUTO_BUILD_TIMES; auto builds disabled." >&2
fi
auto_build_times="$valid_times"
}
artifact_build_retention_is_count() {
[[ "$artifact_build_retention_per_branch" =~ ^[1-9][0-9]*$ ]]
}
artifact_build_retention_cutoff_epoch() {
local amount
amount="${artifact_build_retention_per_branch%[dh]}"
case "$artifact_build_retention_per_branch" in
*h)
echo "$(($(date +%s) - amount * 3600))"
;;
*d)
echo "$(($(date +%s) - amount * 86400))"
;;
esac
}
new_branch_commit_max_age_cutoff_epoch() {
local amount
amount="${new_branch_commit_max_age%[dh]}"
case "$new_branch_commit_max_age" in
*h)
echo "$(($(date +%s) - amount * 3600))"
;;
*d)
echo "$(($(date +%s) - amount * 86400))"
;;
esac
}
detect_gitea_repo() {
detect_gitea_repo_from_origin_url
}
gitea_status_enabled() {
[ -n "$gitea_token" ] &&
[ -n "$gitea_base_url" ] &&
[ -n "$gitea_owner" ] &&
[ -n "$gitea_repo" ] &&
command -v curl >/dev/null 2>&1 &&
command -v python3 >/dev/null 2>&1
}
validate_gitea_git_credentials() {
if [ -n "$gitea_git_username" ] && [ -n "$gitea_token" ]; then
return 0
fi
if [ -z "$gitea_git_username" ]; then
echo "ERROR: GITTALLY_GITEA_GIT_USERNAME must be set before starting $tool_name." >&2
fi
if [ -z "$gitea_token" ]; then
echo "ERROR: GITTALLY_GITEA_TOKEN must be set before starting $tool_name." >&2
fi
echo "Git commands cannot run without both Gitea credentials." >&2
return 1
}
json_string() {
python3 -c 'import json, sys; print(json.dumps(sys.argv[1]))' "$1"
}
origin_uses_https() {
local origin_url
origin_url=$(origin_url)
[[ "$origin_url" =~ ^https?:// ]]
}
origin_url_username() {
detect_git_username_from_origin_url
}
gitea_token_username() {
local user_json
gitea_status_enabled || return 1
user_json=$(curl -fsS \
-H "Authorization: token $gitea_token" \
"${gitea_base_url%/}/api/v1/user" 2>/dev/null) || return 1
printf '%s' "$user_json" | python3 -c 'import json, sys; print(json.load(sys.stdin).get("login", ""))' 2>/dev/null
}
ensure_git_askpass_file() {
if [ -n "$git_askpass_file" ]; then
return 0
fi
git_askpass_file=$(mktemp "${TMPDIR:-/tmp}/git-watch-origin-and-test-askpass.XXXXXX") || return 1
cat >"$git_askpass_file" <<'EOF'
#!/bin/sh
case "$1" in
*Username*|*username*)
printf '%s\n' "$GITTALLY_GITEA_GIT_USERNAME"
;;
*)
printf '%s\n' "$GITTALLY_GITEA_TOKEN"
;;
esac
EOF
chmod 700 "$git_askpass_file"
}
git_with_gitea_token() {
local git_username
if [ -z "$gitea_token" ] || ! origin_uses_https; then
git "$@"
return
fi
ensure_git_askpass_file || return 1
git_username="${gitea_git_username:-}"
if [ -z "$git_username" ]; then
git_username=$(origin_url_username)
fi
if [ -z "$git_username" ]; then
git_username=$(gitea_token_username || true)
fi
if [ -z "$git_username" ]; then
echo "WARNING: GITTALLY_GITEA_TOKEN is set, but no HTTPS git username could be determined." >&2
echo "Set GITTALLY_GITEA_GIT_USERNAME to use the token for git fetch/pull." >&2
git "$@"
return
fi
GITTALLY_GITEA_GIT_USERNAME="$git_username" \
GITTALLY_GITEA_TOKEN="$gitea_token" \
GIT_ASKPASS="$git_askpass_file" \
GIT_TERMINAL_PROMPT=0 \
git "$@"
}
gitea_status_state_for_build_status() {
case "$1" in
success|passed)
echo success
;;
failed|interrupted|cancelled)
echo failure
;;
pending|running)
echo pending
;;
*)
echo error
;;
esac
}
gitea_deleted_status_description() {
echo "Build status deleted"
}
build_status_for_gitea_status_state() {
case "$1" in
success)
echo success
;;
failure|error|warning)
echo failed
;;
pending)
echo running
;;
*)
return 1
;;
esac
}
gitea_status_api_url() {
local sha="$1"
printf '%s/api/v1/repos/%s/%s/statuses/%s' \
"${gitea_base_url%/}" \
"$gitea_owner" \
"$gitea_repo" \
"$sha"
}
gitea_commit_status_api_url() {
local sha="$1"
printf '%s/api/v1/repos/%s/%s/commits/%s/statuses?sort=recentupdate' \
"${gitea_base_url%/}" \
"$gitea_owner" \
"$gitea_repo" \
"$sha"
}
gitea_status_target_url_for_branch() {
local branch="$1"
local artifact_key="${2:-}"
if [ -n "$artifact_http_server_url" ]; then
if [ -z "$artifact_key" ]; then
artifact_key=$(build_artifact_branch_key "$branch")
fi
echo "${artifact_http_server_url}branches/$artifact_key/index.html"
fi
}
publish_gitea_build_status() {
local sha="$1"
local build_status="$2"
local branch="$3"
local artifact_key="${4:-}"
local state
local description
local target_url
local payload
local status_url
gitea_status_enabled || return 0
state=$(gitea_status_state_for_build_status "$build_status")
target_url=$(gitea_status_target_url_for_branch "$branch" "$artifact_key")
case "$build_status" in
success|passed)
description="Build succeeded"
;;
failed)
description="Build failed"
;;
cancelled)
description="Build cancelled"
;;
interrupted)
description="Build interrupted"
;;
pending|running)
description="Build running"
;;
*)
description="Build status unknown"
;;
esac
payload=$(
printf '{"state":%s,"context":%s,"description":%s' \
"$(json_string "$state")" \
"$(json_string "$gitea_status_context")" \
"$(json_string "$description")"
if [ -n "$target_url" ]; then
printf ',"target_url":%s' "$(json_string "$target_url")"
fi
printf '}'
)
status_url=$(gitea_status_api_url "$sha")
if ! curl -fsS \
-H "Authorization: token $gitea_token" \
-H "Content-Type: application/json" \
-X POST \
-d "$payload" \
"$status_url" >/dev/null; then
echo "WARNING: could not publish Gitea build status for $sha." >&2
return 1
fi
}
read_gitea_build_status() {
local sha="$1"
local statuses_json
local state
local response_file
local http_status
gitea_status_enabled || return 1
response_file=$(mktemp "${TMPDIR:-/tmp}/gittally-gitea-status.XXXXXX") || return 1
http_status=$(curl -sS \
-w '%{http_code}' \
-o "$response_file" \
-H "Authorization: token $gitea_token" \
"$(gitea_commit_status_api_url "$sha")") || {
rm -f -- "$response_file"
return 1
}
statuses_json=$(cat "$response_file")
rm -f -- "$response_file"
if [ "$http_status" = 404 ]; then
return 1
fi
if [ "$http_status" -lt 200 ] || [ "$http_status" -ge 300 ]; then
echo "WARNING: could not read Gitea build status for $sha: HTTP $http_status." >&2
return 1
fi
state=$(
GITEA_STATUS_CONTEXT="$gitea_status_context" python3 -c '
import json
import os
import sys
context = os.environ["GITEA_STATUS_CONTEXT"]
statuses = json.load(sys.stdin)
for status in statuses:
if status.get("context") == context:
if status.get("description") == "Build status deleted":
print("deleted")
else:
print(status.get("state", ""))
break
' <<<"$statuses_json"
)
build_status_for_gitea_status_state "$state"
}
effective_build_status() {
local commit="$1"
local status="$2"
case "$status" in
pending|interrupted|cancelled)
echo "$status"
;;
*)
if gitea_status_enabled; then
read_gitea_build_status "$commit" || echo "$status"
else
echo "$status"
fi
;;
esac
}
html_escape() {
sed \
-e 's/&/\&amp;/g' \
-e 's/</\&lt;/g' \
-e 's/>/\&gt;/g' \
-e 's/"/\&quot;/g'
}
url_path_escape() {
local value="$1"
local safe="${2:-}"
if command -v python3 >/dev/null 2>&1; then
python3 -c 'import sys, urllib.parse; print(urllib.parse.quote(sys.argv[1], safe=sys.argv[2]))' "$value" "$safe"
else
printf '%s' "$value"
fi
}
gitea_repo_web_url() {
if [ -n "$gitea_base_url" ] && [ -n "$gitea_owner" ] && [ -n "$gitea_repo" ]; then
printf '%s/%s/%s' "${gitea_base_url%/}" "$gitea_owner" "$gitea_repo"
fi
}
gitea_branch_web_url() {
local branch="$1"
local repo_url
repo_url=$(gitea_repo_web_url)
if [ -n "$repo_url" ]; then
printf '%s/src/branch/%s' "$repo_url" "$(url_path_escape "$branch" '/')"
fi
}
gitea_commit_web_url() {
local commit="$1"
local repo_url
repo_url=$(gitea_repo_web_url)
if [ -n "$repo_url" ]; then
printf '%s/commit/%s' "$repo_url" "$(url_path_escape "$commit")"
fi
}
write_html_link() {
local index_file="$1"
local href="$2"
local label="$3"
printf ' <li><a href="%s">%s</a></li>\n' \
"$(printf '%s' "$href" | html_escape)" \
"$(printf '%s' "$label" | html_escape)" \
>>"$index_file"
}
html_copy_button() {
local value="$1"
local label="$2"
printf '<button class="copy-button" type="button" data-copy="%s" title="Copy %s" aria-label="Copy %s">⧉</button>' \
"$(printf '%s' "$value" | html_escape)" \
"$(printf '%s' "$label" | html_escape)" \
"$(printf '%s' "$label" | html_escape)"
}
write_html_favicon_links() {
local index_file="$1"
local href="${2:-favicon.svg}"
{
printf ' <link rel="icon" href="%s" type="image/svg+xml">\n' "$(printf '%s' "$href" | html_escape)"
printf ' <link rel="shortcut icon" href="%s" type="image/svg+xml">\n' "$(printf '%s' "$href" | html_escape)"
} >>"$index_file"
}
write_html_favicon() {
local artifacts_root="$1"
local icon_file="$artifacts_root/favicon.svg"
mkdir -p "$artifacts_root" || return 1
cat >"$icon_file" <<-'EOF'
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-label="gitTally">
<rect width="64" height="64" rx="14" fill="#155eef"/>
<path d="M17 47V18m0 14h13c7 0 10-4 10-11" fill="none" stroke="#f9fafb" stroke-width="6" stroke-linecap="round" stroke-linejoin="round"/>
<circle cx="17" cy="18" r="5" fill="#DD4901"/>
<circle cx="17" cy="47" r="5" fill="#DD4901"/>
<circle cx="40" cy="21" r="5" fill="#DD4901"/>
<path d="M44 35v14M51 35v14M58 35v14M43 47h16" fill="none" stroke="#f9fafb" stroke-width="4" stroke-linecap="round"/>
</svg>
EOF
}
write_script_download() {
local artifacts_root="$1"
local download_file="$artifacts_root/gitTally.sh"
mkdir -p "$artifacts_root" || return 1
cp "$script_path" "$download_file" || return 1
chmod 644 "$download_file"
}
write_html_footer() {
local index_file="$1"
local license_href="${2:-license.html}"
local about_href="${3:-about.html}"
{
printf ' <footer class="site-footer">'
printf '<a href="%s"><strong><em>gitTally v%s</em></strong></a> <a href="env.html">(env)</a> ' \
"$(printf '%s' "$about_href" | html_escape)" \
"$(printf '%s' "$script_version" | html_escape)"
printf -- '- (c) <a href="https://michael.hoennig.de" target="_blank" rel="noopener noreferrer">Michael Hönnig</a>, 2026 '
printf -- '- Licensed under the <a href="%s">MIT License</a> ' "$(printf '%s' "$license_href" | html_escape)"
printf -- '- <a href="%s" target="_blank" rel="noopener noreferrer">Impressum (Legal Disclosure)</a>' "$(printf '%s' "$impressum_url" | html_escape)"
printf '</footer>\n'
} >>"$index_file"
}
write_html_about_page() {
local artifacts_root="$1"
local index_file="$artifacts_root/about.html"
mkdir -p "$artifacts_root" || return 1
cat >"$index_file" <<-EOF
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>gitTally - About</title>
<link rel="icon" href="favicon.svg" type="image/svg+xml">
<link rel="shortcut icon" href="favicon.svg" type="image/svg+xml">
<style>
:root { color-scheme: light dark; --bg: #f6f8fa; --panel: #ffffff; --text: #1f2937; --muted: #6b7280; --border: #d7dde5; --link: #155eef; }
@media (prefers-color-scheme: dark) { :root { --bg: #111827; --panel: #1f2937; --text: #f3f4f6; --muted: #9ca3af; --border: #374151; --link: #93c5fd; } }
* { box-sizing: border-box; }
body { margin: 0; background: var(--bg); color: var(--text); font: 14px/1.55 system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; }
main { width: min(1180px, calc(100% - 32px)); margin: 32px auto; }
h1 { display: flex; align-items: center; gap: 10px; margin: 0 0 18px; font-size: 28px; font-weight: 700; }
h1 img { width: 32px; height: 32px; flex: none; }
.title-home { display: inline-flex; flex: none; }
h2 { margin: 26px 0 12px; font-size: 18px; }
article { border: 1px solid var(--border); border-radius: 8px; background: var(--panel); padding: 22px; }
article p { margin: 0 0 16px; }
article ul { margin: 0 0 16px 20px; padding: 0; }
article li { margin: 0 0 7px; }
a { color: var(--link); font-weight: 650; text-decoration: none; }
a:hover { text-decoration: underline; }
.view-row { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin: 0 0 18px; }
.view-toggle { display: inline-flex; gap: 0; border: 1px solid var(--border); border-radius: 8px; overflow: hidden; background: var(--panel); }
.view-toggle a, .view-toggle span { display: inline-flex; min-width: 88px; justify-content: center; padding: 7px 12px; font-weight: 700; }
.view-toggle span { background: var(--link); color: white; }
.view-toggle a { color: var(--link); }
.view-toggle a:hover { background: color-mix(in srgb, var(--link) 8%, transparent); text-decoration: none; }
.site-footer { width: min(1180px, calc(100% - 32px)); margin: 24px auto 32px; color: var(--muted); font-size: 12px; }
@media (max-width: 680px) {
body { font-size: 14px; }
main { margin: 16px auto; }
h1 { font-size: 22px; }
.view-toggle a, .view-toggle span { min-width: 0; padding: 6px 9px; font-size: 13px; }
}
</style>
</head>
<body>
<main>
<h1><a class="title-home" href="index.html" aria-label="Open latest builds"><img src="favicon.svg" alt="" aria-hidden="true"></a>About <em>gitTally</em></h1>
<div class="view-row"><nav class="view-toggle" aria-label="Build artifact view"><a href="index.html">Latest</a><a href="branches.html">Branches</a><a href="history.html">Builds</a><a href="current.html">Current</a></nav></div>
<article>
<p><em>gitTally</em> is a deliberately small and opinionated CI and deployment tool
for projects that do not have the hardware budget or operational staff for large CI/CD systems.</p>
<p>It is a <em>Hostsharing</em> community project,
not an official project of <a href="https://www.hostsharing.net/" target="_blank" rel="noopener noreferrer">Hostsharing eG</a>.</p>
<h2>Intention</h2>
<ul>
<li>Configuration is environment-driven, with no UI settings, so installations remain easy to bootstrap and repeatable.</li>
<li>It can be started right within any git working tree, even locally on the developers computer or on a spare computer.</li>
<li>Designed for <a href="https://www.hostsharing.net/container/container-server/" target="_blank" rel="noopener noreferrer">Hostsharing Container Server</a> environments with Docker or Podman.</li>
</ul>
<h2>Operating Model</h2>
<p><em>gitTally</em> watches branches, checks out new commits, runs a configurable build command,
then archives build output for later inspection.
<a href="https://gitea.io/" target="_blank" rel="noopener noreferrer"><em>GitEA</ea></a> integration can publish commit status and protect the artifact website through <em>OAuth2</em> login.</p>
<ul>
<li>Build run directly in the environment or optionally in a <em>Docker</em> container.</li>
<li>The build command is configurable, so <em>gitTally</em> is build-system agnostic.</li>
<li>Build-status for the branches are kept locally and are pushed to a <em>GitEA</em> instance.</li>
</ul>
<h2>Runtime Environment</h2>
<p>The goal is low-cost operation without a dedicated VM per project.
<em>gitTally</em> is meant to run as a normal <em>Linux</em> user without root privileges.</p>
<ul>
<li>Can also run on a local computer for small projects or personal workflows.</li>
<li>Supports systemd user services for unattended operation.</li>
<li>Provides optional <em>nginx</em> reverse-proxy support with <em>Let's Encrypt</em> certificates.</li>
</ul>
<h2>Web Interface</h2>
<p>The web interface exposes the information needed to inspect current and past builds, while staying simple and static.</p>
<ul>
<li>Latest, branches, builds, and current-build views.</li>
<li>Archived stdout, stderr, and reports for each build.</li>
<li>Optional cancellation of the currently running build.</li>
<li>Static HTML served by the built-in artifact HTTP server or through <em>nginx</em>.</li>
</ul>
<h2>Roadmap</h2>
<p><em>gitTally</em> is currently a <em>bash</em> script, developed (mostly vibe-coded) with
<a href="https://www.jetbrains.com/help/ai-assistant/ai-chat.html" target="_blank" rel="noopener noreferrer">IntelliJ IDEA AI Chat</a>,
mainly powered by <a href="https://openai.com/index/introducing-codex/" target="_blank" rel="noopener noreferrer">Codex</a>
and <a href="https://openai.com/index/introducing-gpt-5-5/" target="_blank" rel="noopener noreferrer">GPT-5.5</a>
It may later get refactored to maintainable code in <em>Kotlin</em> or <em>Python</em>.</p>
<p>Planned features: Support for ...</p>
<ul>
<li>Separate the builder from the watcher, so that new branches can get detected during a build.</li>
<li><em>GitEA</em> PRs including green build as quality-gate for merging to master/main,</li>
<li>separate build-command for special branches like master/main.,</li>
<li><em>Docker</em>-based deployments for branches,</li>
<li>rootles <em>Podman</em> environments.</li>
</ul>
<p><a href="gitTally.sh" download="gitTally.sh">Download the current <em>gitTally</em> script</a>.</p>
</article>
</main>
EOF
write_html_footer "$index_file"
{
printf '</body>\n'
printf '</html>\n'
} >>"$index_file"
}
write_html_license_page() {
local artifacts_root="$1"
local index_file="$artifacts_root/license.html"
mkdir -p "$artifacts_root" || return 1
{
printf '<!doctype html>\n'
printf '<html lang="en">\n'
printf '<head>\n'
printf ' <meta charset="utf-8">\n'
printf ' <meta name="viewport" content="width=device-width, initial-scale=1">\n'
printf ' <title>GitTally - MIT License</title>\n'
} >"$index_file"
write_html_favicon_links "$index_file"
{
printf ' <style>\n'
printf ' :root { color-scheme: light dark; --bg: #f6f8fa; --panel: #ffffff; --text: #1f2937; --muted: #6b7280; --border: #d7dde5; --link: #155eef; }\n'
printf ' @media (prefers-color-scheme: dark) { :root { --bg: #111827; --panel: #1f2937; --text: #f3f4f6; --muted: #9ca3af; --border: #374151; --link: #93c5fd; } }\n'
printf ' * { box-sizing: border-box; }\n'
printf ' body { margin: 0; background: var(--bg); color: var(--text); font: 14px/1.55 system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; }\n'
printf ' main { width: min(1180px, calc(100%% - 32px)); margin: 32px auto; }\n'
printf ' h1 { display: flex; align-items: center; gap: 10px; margin: 0 0 18px; font-size: 28px; font-weight: 700; }\n'
printf ' h1 img { width: 32px; height: 32px; flex: none; }\n'
printf ' .title-home { display: inline-flex; flex: none; }\n'
printf ' article { border: 1px solid var(--border); border-radius: 8px; background: var(--panel); padding: 22px; }\n'
printf ' article p { margin: 0 0 16px; }\n'
printf ' article p:last-child { margin-bottom: 0; }\n'
printf ' a { color: var(--link); font-weight: 650; text-decoration: none; }\n'
printf ' a:hover { text-decoration: underline; }\n'
printf ' .view-row { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin: 0 0 18px; }\n'
printf ' .view-toggle { display: inline-flex; gap: 0; border: 1px solid var(--border); border-radius: 8px; overflow: hidden; background: var(--panel); }\n'
printf ' .view-toggle a, .view-toggle span { display: inline-flex; min-width: 88px; justify-content: center; padding: 7px 12px; font-weight: 700; }\n'
printf ' .view-toggle span { background: var(--link); color: white; }\n'
printf ' .view-toggle a { color: var(--link); }\n'
printf ' .view-toggle a:hover { background: color-mix(in srgb, var(--link) 8%%, transparent); text-decoration: none; }\n'
printf ' .site-footer { width: min(1180px, calc(100%% - 32px)); margin: 24px auto 32px; color: var(--muted); font-size: 12px; }\n'
printf ' @media (max-width: 680px) { body { font-size: 14px; } main { margin: 16px auto; } h1 { font-size: 22px; } .view-toggle a, .view-toggle span { min-width: 0; padding: 6px 9px; font-size: 13px; } }\n'
printf ' </style>\n'
printf '</head>\n'
printf '<body>\n'
printf ' <main>\n'
printf ' <h1><a class="title-home" href="index.html" aria-label="Open latest builds"><img src="favicon.svg" alt="" aria-hidden="true"></a>The MIT License</h1>\n'
printf ' <div class="view-row"><nav class="view-toggle" aria-label="Build artifact view"><a href="index.html">Latest</a><a href="branches.html">Branches</a><a href="history.html">Builds</a><a href="current.html">Current</a></nav></div>\n'
printf ' <article>\n'
printf ' <p>Copyright 2026 Michael Hönnig</p>\n'
printf ' <p>Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:</p>\n'
printf ' <p>The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.</p>\n'
printf ' <p>THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.</p>\n'
printf ' </article>\n'
printf ' </main>\n'
} >>"$index_file"
write_html_footer "$index_file"
{
printf '</body>\n'
printf '</html>\n'
} >>"$index_file"
}
write_build_artifact_view_toggle() {
local index_file="$1"
local current_view_label="$2"
local right_html="${3:-}"
printf ' <div class="view-row">\n' >>"$index_file"
printf ' <nav class="view-toggle" aria-label="Build artifact view">\n' >>"$index_file"
if [ "$current_view_label" = "Latest" ]; then
printf ' <span>Latest</span><a href="branches.html">Branches</a><a href="history.html">Builds</a><a href="current.html">Current</a><a href="system.html">System</a>\n' >>"$index_file"
elif [ "$current_view_label" = "Branches" ]; then
printf ' <a href="index.html">Latest</a><span>Branches</span><a href="history.html">Builds</a><a href="current.html">Current</a><a href="system.html">System</a>\n' >>"$index_file"
elif [ "$current_view_label" = "Builds" ]; then
printf ' <a href="index.html">Latest</a><a href="branches.html">Branches</a><span>Builds</span><a href="current.html">Current</a><a href="system.html">System</a>\n' >>"$index_file"
elif [ "$current_view_label" = "Current" ]; then
printf ' <a href="index.html">Latest</a><a href="branches.html">Branches</a><a href="history.html">Builds</a><span>Current</span><a href="system.html">System</a>\n' >>"$index_file"
elif [ "$current_view_label" = "System" ]; then
printf ' <a href="index.html">Latest</a><a href="branches.html">Branches</a><a href="history.html">Builds</a><a href="current.html">Current</a><span>System</span>\n' >>"$index_file"
else
printf ' <a href="index.html">Latest</a><a href="branches.html">Branches</a><a href="history.html">Builds</a><a href="current.html">Current</a><a href="system.html">System</a>\n' >>"$index_file"
fi
printf ' </nav>\n' >>"$index_file"
if [ -n "$right_html" ]; then
printf ' <div class="view-row-actions">%s</div>\n' "$right_html" >>"$index_file"
fi
printf ' </div>\n' >>"$index_file"
}
write_artifacts_root_index_page() {
local view="$1"
local artifacts_root
local index_file
local results_file
local branch
local commit
local status
local display_status
local status_class
local timestamp
local commit_timestamp_value
local display_commit_timestamp
local display_status_timestamp
local duration
local artifact_key
local commit_abbrev
local branch_cell
local branch_copy_button
local branch_url
local commit_cell
local commit_copy_button
local commit_url
local page_title
local current_view_label
local reload_action_html
local return_to
local load_statuses_in_browser=false
local row_class
local local_status
local loading_status=false
local result_line
local has_results=false
local row_index=0
artifacts_root=$(build_artifacts_root)
results_file=$(build_results_file)
if [ "$use_artifact_http_server" = true ] && gitea_status_enabled; then
load_statuses_in_browser=true
fi
write_html_favicon "$artifacts_root" || return 1
write_script_download "$artifacts_root" || return 1
write_html_about_page "$artifacts_root" || return 1
write_html_license_page "$artifacts_root" || return 1
case "$view" in
latest)
index_file="$artifacts_root/index.html"
page_title="GitTally [$(repository_simple_name)] - Latest Branch Builds"
current_view_label="Latest"
;;
branches)
index_file="$artifacts_root/branches.html"
page_title="GitTally [$(repository_simple_name)] - Branches"
current_view_label="Branches"
;;
history|*)
index_file="$artifacts_root/history.html"
page_title="GitTally [$(repository_simple_name)] - Builds"
current_view_label="Builds"
;;
esac
reload_action_html=$(printf '<form class="reload-form" method="get" action="%s"><button class="reload-button" type="submit" title="Reload view" aria-label="Reload view">⟳</button></form>' "$(basename "$index_file")")
mkdir -p "$artifacts_root" || return 1
{
printf '<!doctype html>\n'
printf '<html lang="en">\n'
printf '<head>\n'
printf ' <meta charset="utf-8">\n'
printf ' <meta name="viewport" content="width=device-width, initial-scale=1">\n'
printf ' <meta http-equiv="Cache-Control" content="no-store">\n'
printf ' <meta http-equiv="Pragma" content="no-cache">\n'
printf ' <meta http-equiv="Expires" content="0">\n'
printf ' <title>%s</title>\n' "$(printf '%s' "$page_title" | html_escape)"
} >"$index_file"
write_html_favicon_links "$index_file"
{
printf ' <style>\n'
printf ' :root { color-scheme: light dark; --bg: #f6f8fa; --panel: #ffffff; --text: #1f2937; --muted: #6b7280; --border: #d7dde5; --row: #f9fafb; --link: #155eef; --success-bg: #dcfce7; --success-text: #166534; --failed-bg: #fee2e2; --failed-text: #991b1b; --running-bg: #dbeafe; --running-text: #1d4ed8; --pending-bg: #ede9fe; --pending-text: #5b21b6; --interrupted-bg: #ffedd5; --interrupted-text: #9a3412; --cancelled-bg: #e5e7eb; --cancelled-text: #374151; --unknown-bg: #f3f4f6; --unknown-text: #4b5563; }\n'
printf ' @media (prefers-color-scheme: dark) { :root { --bg: #111827; --panel: #1f2937; --text: #f3f4f6; --muted: #9ca3af; --border: #374151; --row: #182235; --link: #93c5fd; --success-bg: #12351f; --success-text: #86efac; --failed-bg: #3f1717; --failed-text: #fca5a5; --running-bg: #112c55; --running-text: #93c5fd; --pending-bg: #2e1065; --pending-text: #c4b5fd; --interrupted-bg: #431f0b; --interrupted-text: #fdba74; --cancelled-bg: #374151; --cancelled-text: #d1d5db; --unknown-bg: #374151; --unknown-text: #d1d5db; } }\n'
printf ' * { box-sizing: border-box; }\n'
printf ' body { margin: 0; background: var(--bg); color: var(--text); font: 14px/1.45 system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; }\n'
printf ' main { width: min(1180px, calc(100%% - 32px)); margin: 32px auto; }\n'
printf ' h1 { display: flex; align-items: center; gap: 10px; margin: 0 0 18px; font-size: 28px; font-weight: 700; }\n'
printf ' h1 img { width: 32px; height: 32px; flex: none; }\n'
printf ' .title-home { display: inline-flex; flex: none; }\n'
printf ' .table-wrap { overflow-x: auto; border: 1px solid var(--border); border-radius: 8px; background: var(--panel); box-shadow: 0 12px 28px rgb(15 23 42 / 0.08); }\n'
printf ' table { width: 100%%; border-collapse: collapse; min-width: 900px; }\n'
printf ' th, td { padding: 12px 14px; text-align: left; vertical-align: middle; border-bottom: 1px solid var(--border); }\n'
printf ' th { position: sticky; top: 0; background: var(--panel); color: var(--muted); font-size: 12px; font-weight: 700; letter-spacing: 0; text-transform: uppercase; }\n'
printf ' .sort-button { appearance: none; display: inline-flex; align-items: center; gap: 4px; padding: 0; border: 0; background: transparent; color: inherit; font: inherit; font-weight: inherit; letter-spacing: inherit; text-transform: inherit; cursor: pointer; }\n'
printf ' .sort-button:hover { color: var(--link); }\n'
printf ' .sort-button::after { content: "↕"; color: var(--muted); font-size: 10px; }\n'
printf ' .sort-button.is-active[data-sort-direction="asc"]::after { content: "↑"; color: var(--link); }\n'
printf ' .sort-button.is-active[data-sort-direction="desc"]::after { content: "↓"; color: var(--link); }\n'
printf ' tbody tr:nth-child(even) { background: var(--row); }\n'
printf ' tbody tr:last-child td { border-bottom: 0; }\n'
printf ' tbody tr:hover { background: color-mix(in srgb, var(--link) 8%%, transparent); }\n'
printf ' tbody { opacity: 1; transition: opacity 140ms ease-in-out; }\n'
printf ' tbody.is-refreshing { opacity: 0.35; }\n'
printf ' tbody tr.status-loading td:not(.actions-cell) { position: relative; color: transparent; }\n'
printf ' tbody tr.status-loading td:not(.actions-cell) > * { visibility: hidden; }\n'
printf ' tbody tr.status-loading td:not(.actions-cell)::after { content: ""; display: block; height: 14px; width: min(180px, 70%%); border-radius: 999px; background: linear-gradient(90deg, var(--border), color-mix(in srgb, var(--border) 45%%, var(--panel)), var(--border)); background-size: 220%% 100%%; animation: loading-row 1.1s ease-in-out infinite; }\n'
printf ' tbody tr.status-loading td:nth-child(1)::after { width: 72px; }\n'
printf ' tbody tr.status-loading td:nth-child(3)::after { width: 96px; }\n'
printf ' tbody tr.status-loading td:nth-child(4)::after { width: 132px; }\n'
printf ' tbody tr.status-loading td:nth-child(5)::after { width: 132px; }\n'
printf ' tbody tr.status-loading td:nth-child(6)::after { width: 56px; }\n'
printf ' tbody tr.status-loading td:nth-child(7)::after { width: 84px; }\n'
printf ' tbody tr.status-loading .actions { visibility: hidden; }\n'
printf ' @keyframes loading-row { 0%% { background-position: 120%% 0; } 100%% { background-position: -120%% 0; } }\n'
printf ' code { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 13px; }\n'
printf ' a { color: var(--link); font-weight: 650; text-decoration: none; }\n'
printf ' a:hover { text-decoration: underline; }\n'
printf ' .link-tools { display: inline-flex; align-items: center; gap: 5px; max-width: 100%%; }\n'
printf ' .copy-button { appearance: none; display: inline-flex; align-items: center; justify-content: center; width: 22px; height: 22px; border: 1px solid transparent; border-radius: 5px; background: transparent; color: var(--muted); font: 14px/1 system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; cursor: pointer; }\n'
printf ' .copy-button:hover { border-color: var(--border); background: color-mix(in srgb, var(--link) 8%%, transparent); color: var(--link); }\n'
printf ' .copy-button.is-copied { color: var(--success-text); }\n'
printf ' .artifact-link { display: inline-flex; align-items: center; justify-content: center; }\n'
printf ' .artifact-link svg { display: block; }\n'
printf ' .actions-column, .actions-cell { width: 96px; min-width: 96px; }\n'
printf ' .actions { display: inline-flex; align-items: center; justify-content: center; gap: 6px; width: 68px; }\n'
printf ' .action-form { margin: 0; }\n'
printf ' .action-button { appearance: none; display: inline-flex; align-items: center; justify-content: center; width: 30px; height: 30px; border: 1px solid var(--border); border-radius: 6px; background: var(--panel); color: var(--link); font: 20px/1 system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; cursor: pointer; }\n'
printf ' .action-button:hover { background: color-mix(in srgb, var(--link) 8%%, transparent); }\n'
printf ' .action-button:disabled { color: var(--muted); cursor: default; }\n'
printf ' .delete-button { color: var(--failed-text); }\n'
printf ' .status { display: inline-flex; align-items: center; min-width: 72px; justify-content: center; padding: 3px 10px; border-radius: 999px; font-size: 12px; font-weight: 700; text-transform: uppercase; }\n'
printf ' .status-success, .status-passed { background: var(--success-bg); color: var(--success-text); }\n'
printf ' .status-failed { background: var(--failed-bg); color: var(--failed-text); }\n'
printf ' .status-running { background: var(--running-bg); color: var(--running-text); }\n'
printf ' .status-pending { background: var(--pending-bg); color: var(--pending-text); }\n'
printf ' .status-interrupted { background: var(--interrupted-bg); color: var(--interrupted-text); }\n'
printf ' .status-cancelled { background: var(--cancelled-bg); color: var(--cancelled-text); }\n'
printf ' .status-unknown { background: var(--unknown-bg); color: var(--unknown-text); }\n'
printf ' .branch { font-weight: 650; }\n'
printf ' .duration-cell { white-space: nowrap; }\n'
printf ' .running-duration { display: inline-flex; align-items: center; gap: 5px; }\n'
printf ' .running-duration-indicator { color: var(--running-text); font-size: 12px; line-height: 1; }\n'
printf ' .view-row { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin: 0 0 18px; }\n'
printf ' .view-row-actions { margin-left: auto; }\n'
printf ' .view-toggle { display: inline-flex; gap: 0; border: 1px solid var(--border); border-radius: 8px; overflow: hidden; background: var(--panel); }\n'
printf ' .view-toggle a, .view-toggle span { display: inline-flex; min-width: 88px; justify-content: center; padding: 7px 12px; font-weight: 700; }\n'
printf ' .view-toggle span { background: var(--link); color: white; }\n'
printf ' .view-toggle a { color: var(--link); }\n'
printf ' .view-toggle a:hover { background: color-mix(in srgb, var(--link) 8%%, transparent); text-decoration: none; }\n'
printf ' .reload-form { margin: 0; }\n'
printf ' .reload-button { appearance: none; display: inline-flex; align-items: center; justify-content: center; width: 34px; height: 34px; border: 1px solid var(--border); border-radius: 6px; background: var(--panel); color: var(--link); font: 21px/1 system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; cursor: pointer; }\n'
printf ' .reload-button:hover { background: color-mix(in srgb, var(--link) 8%%, transparent); }\n'
printf ' .empty { padding: 28px 14px; color: var(--muted); text-align: center; }\n'
printf ' .site-footer { width: min(1180px, calc(100%% - 32px)); margin: 24px auto 32px; color: var(--muted); font-size: 12px; }\n'
printf ' @media (max-width: 680px) {\n'
printf ' body { font-size: 14px; }\n'
printf ' main { margin: 16px auto; }\n'
printf ' h1 { font-size: 22px; }\n'
printf ' .view-toggle a, .view-toggle span { min-width: 0; padding: 6px 9px; font-size: 13px; }\n'
printf ' .table-wrap { overflow-x: visible; border: none; border-radius: 0; background: transparent; box-shadow: none; }\n'
printf ' table, thead, tbody, tr, td { display: block; }\n'
printf ' table { min-width: 0; }\n'
printf ' thead { display: none; }\n'
printf ' tbody { display: flex; flex-direction: column; gap: 12px; }\n'
printf ' tbody tr { border: 1px solid var(--border); border-radius: 10px; background: var(--panel); overflow: hidden; box-shadow: 0 2px 8px rgb(15 23 42 / 0.07); }\n'
printf ' tbody tr:nth-child(even) { background: var(--panel); }\n'
printf ' tbody tr:hover { background: color-mix(in srgb, var(--link) 5%%, var(--panel)); }\n'
printf ' tbody tr.status-loading td::after { display: none; }\n'
printf ' tbody tr.status-loading td > * { visibility: visible; }\n'
printf ' tbody tr.status-loading { color: var(--text); }\n'
printf ' td { display: flex; align-items: center; gap: 10px; padding: 10px 14px; }\n'
printf ' td + td { border-top: 1px solid color-mix(in srgb, var(--border) 50%%, transparent); }\n'
printf ' td[data-label]::before { content: attr(data-label); width: 90px; flex-shrink: 0; font-size: 11px; font-weight: 700; text-transform: uppercase; color: var(--muted); }\n'
printf ' .actions-cell { justify-content: center; background: color-mix(in srgb, var(--border) 18%%, transparent); padding: 10px 14px; }\n'
printf ' .actions-cell .actions { width: auto; }\n'
printf ' .actions-cell .action-button { width: 40px; height: 40px; font-size: 22px; }\n'
printf ' }\n'
printf ' </style>\n'
printf '</head>\n'
printf '<body>\n'
printf ' <main>\n'
printf ' <h1><a class="title-home" href="index.html" aria-label="Open latest builds"><img src="favicon.svg" alt="" aria-hidden="true"></a>%s</h1>\n' "$(printf '%s' "$page_title" | html_escape)"
} >>"$index_file"
write_build_artifact_view_toggle "$index_file" "$current_view_label" "$reload_action_html"
{
printf ' <div class="table-wrap">\n'
printf ' <table>\n'
printf ' <thead><tr><th>Status</th><th><button class="sort-button" type="button" data-sort="branch">Branch</button></th><th>Commit</th><th><button class="sort-button" type="button" data-sort="commit-time">Commit Time</button></th><th><button class="sort-button" type="button" data-sort="status-time">Status Time</button></th><th>Duration</th><th>Artifacts</th><th class="actions-column">Actions</th></tr></thead>\n'
printf ' <tbody id="build-rows">\n'
} >>"$index_file"
while IFS=$'\t' read -r branch commit status timestamp duration artifact_key; do
if [ -z "$branch" ]; then
continue
fi
has_results=true
normalize_build_result_fields
if [ "$status" = unknown ]; then
artifact_key=
fi
commit_abbrev=${commit:0:12}
local_status="$status"
loading_status=false
if [ "$load_statuses_in_browser" = true ]; then
case "$local_status" in
pending|interrupted|cancelled)
display_status="$local_status"
;;
*)
display_status=loading
loading_status=true
;;
esac
else
display_status=$(effective_build_status "$commit" "$local_status")
fi
status_class="status-$display_status"
row_class="$status_class"
if [ "$loading_status" = true ]; then
row_class="status-loading"
fi
commit_timestamp_value=$(commit_timestamp "$commit")
display_commit_timestamp=$(display_build_timestamp "$commit_timestamp_value")
display_status_timestamp=$(display_build_timestamp "$timestamp")
branch_cell=$(printf '%s' "$branch" | html_escape)
branch_copy_button=
branch_url=$(gitea_branch_web_url "$branch")
if [ -n "$branch_url" ]; then
branch_copy_button=$(html_copy_button "$branch" "branch name")
branch_cell=$(printf '<span class="link-tools"><a href="%s" target="_blank" rel="noopener noreferrer">%s</a>%s</span>' \
"$(printf '%s' "$branch_url" | html_escape)" \
"$branch_cell" \
"$branch_copy_button")
fi
commit_cell=$(printf '%s' "$commit_abbrev" | html_escape)
commit_copy_button=
commit_url=
if [[ "$commit" =~ ^[0-9a-fA-F]{7,40}$ ]]; then
commit_url=$(gitea_commit_web_url "$commit")
fi
if [ -n "$commit_url" ]; then
commit_copy_button=$(html_copy_button "$commit" "full commit ID")
commit_cell=$(printf '<a href="%s" target="_blank" rel="noopener noreferrer">%s</a>' \
"$(printf '%s' "$commit_url" | html_escape)" \
"$commit_cell")
fi
printf ' <tr class="%s" data-row-index="%s" data-branch="%s" data-commit-time="%s" data-status-time="%s" data-artifact-key="%s" data-commit="%s" data-local-status="%s"><td data-label="Status"><span class="status %s">%s</span></td><td class="branch" data-label="Branch">%s</td><td data-label="Commit"><span class="link-tools"><code>%s</code>%s</span></td><td data-label="Commit Time">%s</td><td data-label="Status Time">%s</td><td class="duration-cell" data-label="Duration" data-duration="%s">%s</td><td data-label="Artifacts">' \
"$(printf '%s' "$row_class" | html_escape)" \
"$row_index" \
"$(printf '%s' "$branch" | html_escape)" \
"$(printf '%s' "$commit_timestamp_value" | html_escape)" \
"$(printf '%s' "$timestamp" | html_escape)" \
"$(printf '%s' "$artifact_key" | html_escape)" \
"$(printf '%s' "$commit" | html_escape)" \
"$(printf '%s' "$local_status" | html_escape)" \
"$(printf '%s' "$status_class" | html_escape)" \
"$(printf '%s' "$display_status" | html_escape)" \
"$branch_cell" \
"$commit_cell" \
"$commit_copy_button" \
"$(printf '%s' "$display_commit_timestamp" | html_escape)" \
"$(printf '%s' "$display_status_timestamp" | html_escape)" \
"$(printf '%s' "$duration" | html_escape)" \
"$(printf '%s' "$duration" | html_escape)" \
>>"$index_file"
row_index=$((row_index + 1))
if [ -n "$artifact_key" ] &&
{ [ -f "$artifacts_root/branches/$artifact_key/index.html" ] ||
[ -f "$(build_artifact_index_content_file "$artifacts_root/branches/$artifact_key")" ]; }; then
printf '<a class="artifact-link" href="branches/%s/index.html" title="Open artifacts"><svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/></svg></a>' \
"$(printf '%s' "$artifact_key" | html_escape)" \
>>"$index_file"
else
printf 'n/a' >>"$index_file"
fi
printf '</td><td class="actions-cell"><div class="actions">' >>"$index_file"
if [ "$use_artifact_http_server" = true ]; then
if [ "$view" = "history" ]; then
return_to=history.html
elif [ "$view" = "branches" ]; then
return_to=branches.html
else
return_to=index.html
fi
if [ "$view" != "history" ]; then
printf '<form class="action-form" method="post" action="/control/restart"><input type="hidden" name="branch" value="%s"><input type="hidden" name="commit" value="%s"><input type="hidden" name="return_to" value="%s"><button class="action-button" type="submit" title="Restart build" aria-label="Restart build">↻</button></form>' \
"$(printf '%s' "$branch" | html_escape)" \
"$(printf '%s' "$commit" | html_escape)" \
"$return_to" \
>>"$index_file"
fi
if [ -n "$artifact_key" ]; then
printf '<form class="action-form" method="post" action="/control/delete"><input type="hidden" name="branch" value="%s"><input type="hidden" name="commit" value="%s"><input type="hidden" name="artifact_key" value="%s"><input type="hidden" name="return_to" value="%s"><button class="action-button delete-button" type="submit" title="Delete stored status" aria-label="Delete stored status">×</button></form>' \
"$(printf '%s' "$branch" | html_escape)" \
"$(printf '%s' "$commit" | html_escape)" \
"$(printf '%s' "$artifact_key" | html_escape)" \
"$return_to" \
>>"$index_file"
fi
fi
printf '</div></td></tr>\n' >>"$index_file"
done < <(
if [ "$view" = "latest" ]; then
if [ -f "$results_file" ]; then
sort -t $'\t' -k4,4r "$results_file" | awk -F '\t' '!seen[$1]++'
fi
elif [ "$view" = "branches" ]; then
git for-each-ref --format='%(refname:strip=2)%09%(objectname)' refs/heads |
awk -F '\t' '{
sort_group = 2
if ($1 == "main" || $1 == "master") {
sort_group = 0
} else if (index($1, "/") == 0) {
sort_group = 1
}
printf "%d\t%s\t%s\n", sort_group, $1, $0
}' |
sort -t $'\t' -k1,1n -k2,2 |
cut -f3- |
while IFS=$'\t' read -r branch commit; do
result_line=
if [ -f "$results_file" ]; then
result_line=$(sort -t $'\t' -k4,4r "$results_file" | awk -F '\t' -v wanted="$branch" '$1 == wanted { print; exit }')
fi
if [ -n "$result_line" ]; then
printf '%s\n' "$result_line"
else
printf '%s\t%s\tunknown\t\t\t\n' "$branch" "$commit"
fi
done
elif [ -f "$results_file" ]; then
sort -t $'\t' -k4,4r "$results_file"
fi
)
if [ "$has_results" = false ]; then
if [ "$view" = "latest" ]; then
printf ' <tr><td class="empty" colspan="8">No latest build results found.</td></tr>\n' >>"$index_file"
elif [ "$view" = "branches" ]; then
printf ' <tr><td class="empty" colspan="8">No local branches found.</td></tr>\n' >>"$index_file"
else
printf ' <tr><td class="empty" colspan="8">No builds archived yet.</td></tr>\n' >>"$index_file"
fi
fi
{
printf ' </tbody>\n'
printf ' </table>\n'
printf ' </div>\n'
printf ' </main>\n'
} >>"$index_file"
{
printf ' <script>\n'
printf ' const buildRows = document.getElementById("build-rows");\n'
printf ' const loadStatusesInBrowser = %s;\n' "$load_statuses_in_browser"
printf ' const knownStatuses = new Set(["success", "passed", "failed", "running", "pending", "interrupted", "cancelled", "unknown"]);\n'
printf ' const terminalStatuses = new Set(["success", "passed", "failed", "cancelled", "interrupted"]);\n'
printf ' const resolvedStatusCache = new Map();\n'
printf ' let activeSort = { key: "", direction: "" };\n'
printf ' let serverRowsHtml = buildRows ? buildRows.innerHTML : "";\n'
printf ' let isRefreshingView = false;\n'
printf ' const statusLoadingRows = new WeakSet();\n'
printf ' function branchSortKey(row) {\n'
printf ' const branch = row.dataset.branch || "";\n'
printf ' let group = 2;\n'
printf ' if (branch === "main" || branch === "master") group = 0;\n'
printf ' else if (!branch.includes("/")) group = 1;\n'
printf ' return [group, branch.toLocaleLowerCase(), branch];\n'
printf ' }\n'
printf ' function timeSortValue(row, key) {\n'
printf ' const value = key === "commit-time" ? row.dataset.commitTime : row.dataset.statusTime;\n'
printf ' const time = value ? Date.parse(value) : Number.NaN;\n'
printf ' return Number.isNaN(time) ? 0 : time;\n'
printf ' }\n'
printf ' function compareRows(left, right, key, direction) {\n'
printf ' let result = 0;\n'
printf ' if (key === "branch") {\n'
printf ' const leftKey = branchSortKey(left);\n'
printf ' const rightKey = branchSortKey(right);\n'
printf ' result = leftKey[0] - rightKey[0] || leftKey[1].localeCompare(rightKey[1]) || leftKey[2].localeCompare(rightKey[2]);\n'
printf ' } else {\n'
printf ' result = timeSortValue(left, key) - timeSortValue(right, key);\n'
printf ' }\n'
printf ' if (result === 0) {\n'
printf ' result = Number(left.dataset.rowIndex || 0) - Number(right.dataset.rowIndex || 0);\n'
printf ' }\n'
printf ' return direction === "desc" ? -result : result;\n'
printf ' }\n'
printf ' function updateSortButtons() {\n'
printf ' document.querySelectorAll(".sort-button[data-sort]").forEach((button) => {\n'
printf ' const isActive = button.dataset.sort === activeSort.key;\n'
printf ' button.classList.toggle("is-active", isActive);\n'
printf ' button.dataset.sortDirection = isActive ? activeSort.direction : "";\n'
printf ' });\n'
printf ' }\n'
printf ' function sortRows(key, direction) {\n'
printf ' if (!buildRows) return;\n'
printf ' activeSort = { key, direction };\n'
printf ' const rows = Array.from(buildRows.querySelectorAll("tr[data-branch]"));\n'
printf ' rows.sort((left, right) => compareRows(left, right, key, direction));\n'
printf ' rows.forEach((row) => buildRows.appendChild(row));\n'
printf ' updateSortButtons();\n'
printf ' }\n'
printf ' function rowCacheKey(row) {\n'
printf ' return (row.dataset.commit || "") + ":" + (row.dataset.artifactKey || "");\n'
printf ' }\n'
printf ' function applyStatus(row, status) {\n'
printf ' const normalized = knownStatuses.has(status) ? status : row.dataset.localStatus;\n'
printf ' const statusCell = row.querySelector(".status");\n'
printf ' if (!statusCell) return;\n'
printf ' row.className = "status-" + normalized;\n'
printf ' statusCell.className = "status status-" + normalized;\n'
printf ' statusCell.textContent = normalized;\n'
printf ' updateRunningDuration(row);\n'
printf ' if (terminalStatuses.has(normalized)) resolvedStatusCache.set(rowCacheKey(row), normalized);\n'
printf ' }\n'
printf ' function applyCachedStatuses() {\n'
printf ' if (!buildRows) return;\n'
printf ' buildRows.querySelectorAll("tr.status-loading[data-commit]").forEach(row => {\n'
printf ' const cached = resolvedStatusCache.get(rowCacheKey(row));\n'
printf ' if (cached) applyStatus(row, cached);\n'
printf ' });\n'
printf ' }\n'
printf ' function formatDuration(seconds) {\n'
printf ' const safeSeconds = Math.max(0, Math.floor(seconds));\n'
printf ' const minutes = Math.floor(safeSeconds / 60);\n'
printf ' const remainingSeconds = safeSeconds %% 60;\n'
printf ' return String(minutes).padStart(2, "0") + ":" + String(remainingSeconds).padStart(2, "0");\n'
printf ' }\n'
printf ' function rowStatus(row) {\n'
printf ' const statusCell = row.querySelector(".status");\n'
printf ' return statusCell ? statusCell.textContent.trim() : row.dataset.localStatus;\n'
printf ' }\n'
printf ' function updateRunningDuration(row) {\n'
printf ' const durationCell = row.querySelector(".duration-cell");\n'
printf ' if (!durationCell) return;\n'
printf ' if (rowStatus(row) !== "running") {\n'
printf ' durationCell.textContent = durationCell.dataset.duration || "";\n'
printf ' return;\n'
printf ' }\n'
printf ' const startedAt = Date.parse(row.dataset.statusTime || "");\n'
printf ' if (Number.isNaN(startedAt)) return;\n'
printf ' const elapsedSeconds = (Date.now() - startedAt) / 1000;\n'
printf ' durationCell.innerHTML = "";\n'
printf ' const wrapper = document.createElement("span");\n'
printf ' wrapper.className = "running-duration";\n'
printf ' const value = document.createElement("span");\n'
printf ' value.textContent = formatDuration(elapsedSeconds);\n'
printf ' const indicator = document.createElement("span");\n'
printf ' indicator.className = "running-duration-indicator";\n'
printf ' indicator.title = "Build is still running";\n'
printf ' indicator.setAttribute("aria-label", "Build is still running");\n'
printf ' indicator.textContent = "⏱";\n'
printf ' wrapper.append(value, indicator);\n'
printf ' durationCell.append(wrapper);\n'
printf ' }\n'
printf ' function updateRunningDurations() {\n'
printf ' if (!buildRows) return;\n'
printf ' buildRows.querySelectorAll("tr[data-status-time]").forEach(updateRunningDuration);\n'
printf ' }\n'
printf ' async function loadStatus(row) {\n'
printf ' if (!loadStatusesInBrowser || !row.dataset.commit || !/^[0-9a-fA-F]{7,40}$/.test(row.dataset.commit)) return;\n'
printf ' if (!row.classList.contains("status-loading")) return;\n'
printf ' if (statusLoadingRows.has(row)) return;\n'
printf ' statusLoadingRows.add(row);\n'
printf ' const params = new URLSearchParams({ commit: row.dataset.commit, local_status: row.dataset.localStatus || "unknown" });\n'
printf ' const controller = new AbortController();\n'
printf ' const timeoutId = window.setTimeout(() => controller.abort(), 15000);\n'
printf ' try {\n'
printf ' const response = await fetch("/control/status?" + params.toString(), { cache: "no-store", signal: controller.signal });\n'
printf ' const body = response.ok ? await response.json() : null;\n'
printf ' applyStatus(row, body && body.status ? body.status : row.dataset.localStatus);\n'
printf ' } catch (error) {\n'
printf ' applyStatus(row, row.dataset.localStatus);\n'
printf ' } finally {\n'
printf ' window.clearTimeout(timeoutId);\n'
printf ' statusLoadingRows.delete(row);\n'
printf ' }\n'
printf ' }\n'
printf ' function loadVisibleStatuses() {\n'
printf ' if (!loadStatusesInBrowser || !buildRows) return;\n'
printf ' Array.from(buildRows.querySelectorAll("tr[data-commit]")).forEach(loadStatus);\n'
printf ' }\n'
printf ' async function copyToClipboard(button) {\n'
printf ' const text = button.dataset.copy || "";\n'
printf ' if (!text) return;\n'
printf ' try {\n'
printf ' if (navigator.clipboard && window.isSecureContext) {\n'
printf ' await navigator.clipboard.writeText(text);\n'
printf ' } else {\n'
printf ' const input = document.createElement("textarea");\n'
printf ' input.value = text;\n'
printf ' input.style.position = "fixed";\n'
printf ' input.style.left = "-9999px";\n'
printf ' document.body.appendChild(input);\n'
printf ' input.focus();\n'
printf ' input.select();\n'
printf ' document.execCommand("copy");\n'
printf ' input.remove();\n'
printf ' }\n'
printf ' button.classList.add("is-copied");\n'
printf ' window.setTimeout(() => button.classList.remove("is-copied"), 900);\n'
printf ' } catch (error) {\n'
printf ' return;\n'
printf ' }\n'
printf ' }\n'
printf ' function replaceRowsSmoothly(nextRowsHtml) {\n'
printf ' if (!buildRows) return;\n'
printf ' buildRows.classList.add("is-refreshing");\n'
printf ' window.setTimeout(() => {\n'
printf ' buildRows.innerHTML = nextRowsHtml;\n'
printf ' if (activeSort.key) sortRows(activeSort.key, activeSort.direction);\n'
printf ' applyCachedStatuses();\n'
printf ' loadVisibleStatuses();\n'
printf ' updateRunningDurations();\n'
printf ' window.requestAnimationFrame(() => buildRows.classList.remove("is-refreshing"));\n'
printf ' }, 140);\n'
printf ' }\n'
printf ' async function refreshViewIfChanged() {\n'
printf ' if (!buildRows || isRefreshingView) return;\n'
printf ' isRefreshingView = true;\n'
printf ' try {\n'
printf ' const response = await fetch(window.location.pathname + "?ts=" + Date.now(), { cache: "no-store" });\n'
printf ' if (!response.ok) return;\n'
printf ' const html = await response.text();\n'
printf ' const doc = new DOMParser().parseFromString(html, "text/html");\n'
printf ' const nextRows = doc.getElementById("build-rows");\n'
printf ' if (!nextRows) return;\n'
printf ' const nextRowsHtml = nextRows.innerHTML;\n'
printf ' if (nextRowsHtml !== serverRowsHtml) {\n'
printf ' serverRowsHtml = nextRowsHtml;\n'
printf ' replaceRowsSmoothly(nextRowsHtml);\n'
printf ' } else {\n'
printf ' loadVisibleStatuses();\n'
printf ' }\n'
printf ' } catch (error) {\n'
printf ' return;\n'
printf ' } finally {\n'
printf ' isRefreshingView = false;\n'
printf ' }\n'
printf ' }\n'
printf ' loadVisibleStatuses();\n'
printf ' updateRunningDurations();\n'
printf ' document.addEventListener("click", (event) => {\n'
printf ' const button = event.target.closest(".copy-button[data-copy]");\n'
printf ' if (!button) return;\n'
printf ' event.preventDefault();\n'
printf ' copyToClipboard(button);\n'
printf ' });\n'
printf ' document.addEventListener("click", (event) => {\n'
printf ' const button = event.target.closest(".sort-button[data-sort]");\n'
printf ' if (!button) return;\n'
printf ' const key = button.dataset.sort;\n'
printf ' let direction = key === "branch" ? "asc" : "desc";\n'
printf ' if (activeSort.key === key) {\n'
printf ' direction = activeSort.direction === "asc" ? "desc" : "asc";\n'
printf ' }\n'
printf ' sortRows(key, direction);\n'
printf ' });\n'
printf ' updateSortButtons();\n'
printf ' let runningDurationTimer = null;\n'
printf ' let refreshViewTimer = null;\n'
printf ' function startAutoRefresh() {\n'
printf ' if (runningDurationTimer === null) runningDurationTimer = window.setInterval(updateRunningDurations, 1000);\n'
printf ' if (refreshViewTimer === null) refreshViewTimer = window.setInterval(refreshViewIfChanged, 15000);\n'
printf ' }\n'
printf ' function stopAutoRefresh() {\n'
printf ' if (runningDurationTimer !== null) window.clearInterval(runningDurationTimer);\n'
printf ' if (refreshViewTimer !== null) window.clearInterval(refreshViewTimer);\n'
printf ' runningDurationTimer = null;\n'
printf ' refreshViewTimer = null;\n'
printf ' }\n'
printf ' window.addEventListener("pageshow", () => { updateRunningDurations(); refreshViewIfChanged(); startAutoRefresh(); });\n'
printf ' window.addEventListener("pagehide", stopAutoRefresh);\n'
printf ' startAutoRefresh();\n'
printf ' </script>\n'
} >>"$index_file"
write_html_footer "$index_file"
{
printf '</body>\n'
printf '</html>\n'
} >>"$index_file"
}
current_build_log_file() {
echo "$(build_artifacts_root)/current.log"
}
write_current_build_page() {
local branch="${1:-}"
local status="${2:-idle}"
local timestamp="${3:-}"
local artifacts_root
local index_file
local page_title
local display_timestamp
local cancel_token
local cancel_action_html=
artifacts_root=$(build_artifacts_root)
write_html_favicon "$artifacts_root" || return 1
write_script_download "$artifacts_root" || return 1
write_html_about_page "$artifacts_root" || return 1
write_html_license_page "$artifacts_root" || return 1
index_file="$artifacts_root/current.html"
page_title="GitTally [$(repository_simple_name)] - Current Branch Build"
display_timestamp=$(display_build_timestamp "$timestamp")
if [ "$status" = running ]; then
cancel_token=$(read_build_cancel_token)
if [ -n "$cancel_token" ]; then
cancel_action_html=$(printf '<form id="cancel-form" class="cancel-form" method="post" action="/control/cancel"><input type="hidden" name="token" value="%s"><button class="cancel-button" type="submit">Cancel build</button><span id="cancel-status" class="cancel-status"></span></form>' "$(printf '%s' "$cancel_token" | html_escape)")
fi
fi
mkdir -p "$artifacts_root" || return 1
{
printf '<!doctype html>\n'
printf '<html lang="en">\n'
printf '<head>\n'
printf ' <meta charset="utf-8">\n'
printf ' <meta name="viewport" content="width=device-width, initial-scale=1">\n'
printf ' <meta http-equiv="Cache-Control" content="no-store">\n'
printf ' <meta http-equiv="Pragma" content="no-cache">\n'
printf ' <meta http-equiv="Expires" content="0">\n'
printf ' <title>%s</title>\n' "$(printf '%s' "$page_title" | html_escape)"
} >"$index_file"
write_html_favicon_links "$index_file"
{
printf ' <style>\n'
printf ' :root { color-scheme: light dark; --bg: #f6f8fa; --panel: #ffffff; --text: #1f2937; --muted: #6b7280; --border: #d7dde5; --link: #155eef; }\n'
printf ' @media (prefers-color-scheme: dark) { :root { --bg: #111827; --panel: #1f2937; --text: #f3f4f6; --muted: #9ca3af; --border: #374151; --link: #93c5fd; } }\n'
printf ' * { box-sizing: border-box; }\n'
printf ' body { margin: 0; background: var(--bg); color: var(--text); font: 14px/1.45 system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; }\n'
printf ' main { width: min(1180px, calc(100%% - 32px)); margin: 32px auto; }\n'
printf ' h1 { display: flex; align-items: center; gap: 10px; margin: 0 0 18px; font-size: 28px; font-weight: 700; }\n'
printf ' h1 img { width: 32px; height: 32px; flex: none; }\n'
printf ' .title-home { display: inline-flex; flex: none; }\n'
printf ' a { color: var(--link); font-weight: 650; text-decoration: none; }\n'
printf ' a:hover { text-decoration: underline; }\n'
printf ' .view-row { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin: 0 0 18px; }\n'
printf ' .view-row-actions { margin-left: auto; }\n'
printf ' .view-toggle { display: inline-flex; gap: 0; border: 1px solid var(--border); border-radius: 8px; overflow: hidden; background: var(--panel); }\n'
printf ' .view-toggle a, .view-toggle span { display: inline-flex; min-width: 88px; justify-content: center; padding: 7px 12px; font-weight: 700; }\n'
printf ' .view-toggle span { background: var(--link); color: white; }\n'
printf ' .view-toggle a { color: var(--link); }\n'
printf ' .meta { margin: 0 0 14px; color: var(--muted); }\n'
printf ' .cancel-form { display: flex; align-items: center; justify-content: flex-end; gap: 10px; margin: 0; }\n'
printf ' .cancel-button { appearance: none; border: 1px solid #991b1b; border-radius: 6px; background: #dc2626; color: white; padding: 7px 12px; font: inherit; font-weight: 700; cursor: pointer; }\n'
printf ' .cancel-button:hover { background: #b91c1c; }\n'
printf ' .cancel-status { color: var(--muted); }\n'
printf ' .log { min-height: 65vh; max-height: 72vh; overflow: auto; border: 1px solid var(--border); border-radius: 8px; background: var(--panel); padding: 14px; white-space: pre-wrap; overflow-wrap: anywhere; font: 13px/1.45 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }\n'
printf ' .site-footer { width: min(1180px, calc(100%% - 32px)); margin: 24px auto 32px; color: var(--muted); font-size: 12px; }\n'
printf ' @media (max-width: 680px) { body { font-size: 14px; } main { margin: 16px auto; } h1 { font-size: 22px; } .view-toggle a, .view-toggle span { min-width: 0; padding: 6px 9px; font-size: 13px; } .log { font-size: 13px; min-height: 50vh; max-height: 60vh; } }\n'
printf ' </style>\n'
printf '</head>\n'
printf '<body>\n'
printf ' <main>\n'
printf ' <h1><a class="title-home" href="index.html" aria-label="Open latest builds"><img src="favicon.svg" alt="" aria-hidden="true"></a>%s</h1>\n' "$(printf '%s' "$page_title" | html_escape)"
} >>"$index_file"
write_build_artifact_view_toggle "$index_file" Current "$cancel_action_html"
{
if [ -n "$branch" ]; then
printf ' <p class="meta">Status: %s | Branch: %s' \
"$(printf '%s' "$status" | html_escape)" \
"$(printf '%s' "$branch" | html_escape)"
if [ -n "$display_timestamp" ]; then
printf ' | Started: %s' "$(printf '%s' "$display_timestamp" | html_escape)"
fi
printf '</p>\n'
else
printf ' <p class="meta">No build is currently running.</p>\n'
fi
printf ' <pre id="log" class="log">Loading current.log...</pre>\n'
printf ' </main>\n'
printf ' <script>\n'
printf ' const log = document.getElementById("log");\n'
printf ' const cancelForm = document.getElementById("cancel-form");\n'
printf ' const cancelStatus = document.getElementById("cancel-status");\n'
printf ' let previous = "";\n'
printf ' async function refreshLog() {\n'
printf ' try {\n'
printf ' const response = await fetch("current.log?ts=" + Date.now(), { cache: "no-store" });\n'
printf ' const text = response.ok ? await response.text() : "";\n'
printf ' if (text !== previous) {\n'
printf ' previous = text;\n'
printf ' log.textContent = text || "No build output available.";\n'
printf ' log.scrollTop = log.scrollHeight;\n'
printf ' }\n'
printf ' } catch (error) {\n'
printf ' log.textContent = "Could not load current.log.";\n'
printf ' }\n'
printf ' }\n'
printf ' if (cancelForm) {\n'
printf ' cancelForm.addEventListener("submit", async (event) => {\n'
printf ' event.preventDefault();\n'
printf ' cancelStatus.textContent = "Requesting cancellation...";\n'
printf ' try {\n'
printf ' const response = await fetch(cancelForm.action, { method: "POST", body: new URLSearchParams(new FormData(cancelForm)), cache: "no-store" });\n'
printf ' cancelStatus.textContent = response.ok ? "Cancellation requested." : "Cancellation request failed.";\n'
printf ' } catch (error) {\n'
printf ' cancelStatus.textContent = "Cancellation request failed.";\n'
printf ' }\n'
printf ' });\n'
printf ' }\n'
printf ' let refreshLogTimer = null;\n'
printf ' function startLogAutoRefresh() {\n'
printf ' if (refreshLogTimer === null) refreshLogTimer = window.setInterval(refreshLog, 2000);\n'
printf ' }\n'
printf ' function stopLogAutoRefresh() {\n'
printf ' if (refreshLogTimer !== null) window.clearInterval(refreshLogTimer);\n'
printf ' refreshLogTimer = null;\n'
printf ' }\n'
printf ' window.addEventListener("pageshow", () => { refreshLog(); startLogAutoRefresh(); });\n'
printf ' window.addEventListener("pagehide", stopLogAutoRefresh);\n'
printf ' refreshLog();\n'
printf ' startLogAutoRefresh();\n'
printf ' </script>\n'
} >>"$index_file"
write_html_footer "$index_file"
{
printf '</body>\n'
printf '</html>\n'
} >>"$index_file"
}
system_page_snapshot() {
local system_file="$1"
python3 - "$system_file" <<'PY'
import datetime
import json
import math
import sys
placeholder = "\u2014"
keys = [
"cpu_used", "cpu_used_min", "cpu_used_max", "cpu_used_avg",
"cpu_idle", "cpu_idle_min", "cpu_idle_max", "cpu_idle_avg",
"ram_used_gib", "ram_used_gib_min", "ram_used_gib_max", "ram_used_gib_avg",
"ram_free_gib", "ram_free_gib_min", "ram_free_gib_max", "ram_free_gib_avg",
"disk_used_gib", "disk_used_gib_min", "disk_used_gib_max", "disk_used_gib_avg",
"disk_free_gib", "disk_free_gib_min", "disk_free_gib_max", "disk_free_gib_avg",
"repo_size_gib", "repo_size_gib_min", "repo_size_gib_max", "repo_size_gib_avg",
]
def fmt(value):
try:
val = float(value)
if math.isfinite(val):
return f"{val:.2f}"
except (ValueError, TypeError):
pass
return placeholder
try:
with open(sys.argv[1], encoding="utf-8") as input_file:
data = json.load(input_file)
except Exception:
data = {}
values = [fmt(data.get(key)) for key in keys]
cpu_count = data.get("cpu_count")
values.append(f"{int(cpu_count)} cores" if cpu_count is not None and str(cpu_count).isdigit() else placeholder)
ram_total = data.get("ram_total_gib")
values.append(fmt(ram_total) + " GiB" if ram_total is not None else placeholder)
disk_total = data.get("disk_total_gib")
values.append(fmt(disk_total) + " GiB" if disk_total is not None else placeholder)
timestamp = data.get("timestamp")
if isinstance(timestamp, str) and timestamp:
try:
values.append(datetime.datetime.fromisoformat(timestamp).strftime("%H:%M:%S"))
except ValueError:
values.append(timestamp)
else:
values.append(placeholder)
print("\t".join(values))
PY
}
write_system_page() {
local artifacts_root
local index_file
local page_title
local cpu_used cpu_used_min cpu_used_max cpu_used_avg
local cpu_idle cpu_idle_min cpu_idle_max cpu_idle_avg
local ram_used ram_used_min ram_used_max ram_used_avg
local ram_free ram_free_min ram_free_max ram_free_avg
local disk_used disk_used_min disk_used_max disk_used_avg
local disk_free disk_free_min disk_free_max disk_free_avg
local repo_size repo_size_min repo_size_max repo_size_avg
local cpu_count ram_total disk_total updated
artifacts_root=$(build_artifacts_root)
write_html_favicon "$artifacts_root" || return 1
index_file="$artifacts_root/system.html"
page_title="GitTally [$(repository_simple_name)] - System"
reload_action_html=$(printf '<form class="reload-form" method="get" action="%s"><button class="reload-button" type="submit" title="Reload view" aria-label="Reload view">⟳</button></form>' "$(basename "$index_file")")
mkdir -p "$artifacts_root" || return 1
IFS=$'\t' read -r \
cpu_used cpu_used_min cpu_used_max cpu_used_avg \
cpu_idle cpu_idle_min cpu_idle_max cpu_idle_avg \
ram_used ram_used_min ram_used_max ram_used_avg \
ram_free ram_free_min ram_free_max ram_free_avg \
disk_used disk_used_min disk_used_max disk_used_avg \
disk_free disk_free_min disk_free_max disk_free_avg \
repo_size repo_size_min repo_size_max repo_size_avg \
cpu_count ram_total disk_total updated < <(system_page_snapshot "$artifacts_root/system.json")
{
printf '<!doctype html>\n'
printf '<html lang="en">\n'
printf '<head>\n'
printf ' <meta charset="utf-8">\n'
printf ' <meta name="viewport" content="width=device-width, initial-scale=1">\n'
printf ' <meta http-equiv="Cache-Control" content="no-store">\n'
printf ' <meta http-equiv="Pragma" content="no-cache">\n'
printf ' <meta http-equiv="Expires" content="0">\n'
printf ' <title>%s</title>\n' "$(printf '%s' "$page_title" | html_escape)"
} >"$index_file"
write_html_favicon_links "$index_file"
{
printf ' <style>\n'
printf ' :root { color-scheme: light dark; --bg: #f6f8fa; --panel: #ffffff; --text: #1f2937; --muted: #6b7280; --border: #d7dde5; --row: #f9fafb; --link: #155eef; }\n'
printf ' @media (prefers-color-scheme: dark) { :root { --bg: #111827; --panel: #1f2937; --text: #f3f4f6; --muted: #9ca3af; --border: #374151; --row: #182235; --link: #93c5fd; } }\n'
printf ' * { box-sizing: border-box; }\n'
printf ' body { margin: 0; background: var(--bg); color: var(--text); font: 14px/1.45 system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; }\n'
printf ' main { width: min(1180px, calc(100%% - 32px)); margin: 32px auto; }\n'
printf ' h1 { display: flex; align-items: center; gap: 10px; margin: 0 0 18px; font-size: 28px; font-weight: 700; }\n'
printf ' h1 img { width: 32px; height: 32px; flex: none; }\n'
printf ' .title-home { display: inline-flex; flex: none; }\n'
printf ' a { color: var(--link); font-weight: 650; text-decoration: none; }\n'
printf ' a:hover { text-decoration: underline; }\n'
printf ' .view-row { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin: 0 0 18px; }\n'
printf ' .view-toggle { display: inline-flex; gap: 0; border: 1px solid var(--border); border-radius: 8px; overflow: hidden; background: var(--panel); }\n'
printf ' .view-toggle a, .view-toggle span { display: inline-flex; min-width: 88px; justify-content: center; padding: 7px 12px; font-weight: 700; }\n'
printf ' .view-toggle span { background: var(--link); color: white; }\n'
printf ' .view-toggle a { color: var(--link); }\n'
printf ' .view-toggle a:hover { background: color-mix(in srgb, var(--link) 8%%, transparent); text-decoration: none; }\n'
printf ' .reload-form { margin: 0; }\n'
printf ' .reload-button { appearance: none; display: inline-flex; align-items: center; justify-content: center; width: 34px; height: 34px; border: 1px solid var(--border); border-radius: 6px; background: var(--panel); color: var(--link); font: 21px/1 system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; cursor: pointer; }\n'
printf ' .reload-button:hover { background: color-mix(in srgb, var(--link) 8%%, transparent); }\n'
printf ' .table-wrap { overflow-x: auto; border: 1px solid var(--border); border-radius: 8px; background: var(--panel); box-shadow: 0 12px 28px rgb(15 23 42 / 0.08); }\n'
printf ' table { width: 100%%; border-collapse: collapse; }\n'
printf ' th, td { padding: 12px 14px; text-align: left; vertical-align: middle; border-bottom: 1px solid var(--border); }\n'
printf ' th { background: var(--panel); color: var(--muted); font-size: 12px; font-weight: 700; letter-spacing: 0; text-transform: uppercase; }\n'
printf ' tbody tr:nth-child(even) { background: var(--row); }\n'
printf ' tbody tr:last-child td { border-bottom: 0; }\n'
printf ' .num { text-align: right; font-variant-numeric: tabular-nums; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 13px; }\n'
printf ' .meta { margin: 14px 0 0; color: var(--muted); font-size: 13px; display: flex; justify-content: space-between; align-items: baseline; flex-wrap: wrap; gap: 6px; }\n'
printf ' .site-footer { width: min(1180px, calc(100%% - 32px)); margin: 24px auto 32px; color: var(--muted); font-size: 12px; }\n'
printf ' @media (max-width: 680px) {\n'
printf ' body { font-size: 14px; }\n'
printf ' main { margin: 16px auto; }\n'
printf ' h1 { font-size: 22px; }\n'
printf ' .view-toggle a, .view-toggle span { min-width: 0; padding: 6px 9px; font-size: 13px; }\n'
printf ' .table-wrap { overflow-x: visible; border: none; border-radius: 0; background: transparent; box-shadow: none; }\n'
printf ' table, thead, tbody, tr, td { display: block; }\n'
printf ' table { min-width: 0; }\n'
printf ' thead { display: none; }\n'
printf ' tbody { display: flex; flex-direction: column; gap: 12px; }\n'
printf ' tbody tr { border: 1px solid var(--border); border-radius: 10px; background: var(--panel); overflow: hidden; }\n'
printf ' tbody tr:nth-child(even) { background: var(--panel); }\n'
printf ' td { display: flex; align-items: center; gap: 10px; padding: 10px 14px; }\n'
printf ' td + td { border-top: 1px solid color-mix(in srgb, var(--border) 50%%, transparent); }\n'
printf ' td[data-label]::before { content: attr(data-label); width: 80px; flex-shrink: 0; font-size: 11px; font-weight: 700; text-transform: uppercase; color: var(--muted); }\n'
printf ' .num { text-align: left; }\n'
printf ' }\n'
printf ' </style>\n'
printf '</head>\n'
printf '<body>\n'
printf ' <main>\n'
printf ' <h1><a class="title-home" href="index.html" aria-label="Open latest builds"><img src="favicon.svg" alt="" aria-hidden="true"></a>%s</h1>\n' "$(printf '%s' "$page_title" | html_escape)"
} >>"$index_file"
write_build_artifact_view_toggle "$index_file" System "$reload_action_html"
{
printf ' <div class="table-wrap">\n'
printf ' <table>\n'
printf ' <thead><tr><th>Metric</th><th class="num">Current</th><th class="num">Min</th><th class="num">Max</th><th class="num">Avg</th></tr></thead>\n'
printf ' <tbody>\n'
printf ' <tr><td>CPU used (cores)</td><td id="cur-cpu-used" class="num" data-label="Current">%s</td><td id="min-cpu-used" class="num" data-label="Min">%s</td><td id="max-cpu-used" class="num" data-label="Max">%s</td><td id="avg-cpu-used" class="num" data-label="Avg">%s</td></tr>\n' \
"$(printf '%s' "$cpu_used" | html_escape)" "$(printf '%s' "$cpu_used_min" | html_escape)" "$(printf '%s' "$cpu_used_max" | html_escape)" "$(printf '%s' "$cpu_used_avg" | html_escape)"
printf ' <tr><td>CPU idle (cores)</td><td id="cur-cpu-idle" class="num" data-label="Current">%s</td><td id="min-cpu-idle" class="num" data-label="Min">%s</td><td id="max-cpu-idle" class="num" data-label="Max">%s</td><td id="avg-cpu-idle" class="num" data-label="Avg">%s</td></tr>\n' \
"$(printf '%s' "$cpu_idle" | html_escape)" "$(printf '%s' "$cpu_idle_min" | html_escape)" "$(printf '%s' "$cpu_idle_max" | html_escape)" "$(printf '%s' "$cpu_idle_avg" | html_escape)"
printf ' <tr><td>RAM used (GiB)</td><td id="cur-ram-used" class="num" data-label="Current">%s</td><td id="min-ram-used" class="num" data-label="Min">%s</td><td id="max-ram-used" class="num" data-label="Max">%s</td><td id="avg-ram-used" class="num" data-label="Avg">%s</td></tr>\n' \
"$(printf '%s' "$ram_used" | html_escape)" "$(printf '%s' "$ram_used_min" | html_escape)" "$(printf '%s' "$ram_used_max" | html_escape)" "$(printf '%s' "$ram_used_avg" | html_escape)"
printf ' <tr><td>RAM free (GiB)</td><td id="cur-ram-avail" class="num" data-label="Current">%s</td><td id="min-ram-avail" class="num" data-label="Min">%s</td><td id="max-ram-avail" class="num" data-label="Max">%s</td><td id="avg-ram-avail" class="num" data-label="Avg">%s</td></tr>\n' \
"$(printf '%s' "$ram_free" | html_escape)" "$(printf '%s' "$ram_free_min" | html_escape)" "$(printf '%s' "$ram_free_max" | html_escape)" "$(printf '%s' "$ram_free_avg" | html_escape)"
printf ' <tr><td>Disk used (GiB)</td><td id="cur-disk-used" class="num" data-label="Current">%s</td><td id="min-disk-used" class="num" data-label="Min">%s</td><td id="max-disk-used" class="num" data-label="Max">%s</td><td id="avg-disk-used" class="num" data-label="Avg">%s</td></tr>\n' \
"$(printf '%s' "$disk_used" | html_escape)" "$(printf '%s' "$disk_used_min" | html_escape)" "$(printf '%s' "$disk_used_max" | html_escape)" "$(printf '%s' "$disk_used_avg" | html_escape)"
printf ' <tr><td>Disk free (GiB)</td><td id="cur-disk-avail" class="num" data-label="Current">%s</td><td id="min-disk-avail" class="num" data-label="Min">%s</td><td id="max-disk-avail" class="num" data-label="Max">%s</td><td id="avg-disk-avail" class="num" data-label="Avg">%s</td></tr>\n' \
"$(printf '%s' "$disk_free" | html_escape)" "$(printf '%s' "$disk_free_min" | html_escape)" "$(printf '%s' "$disk_free_max" | html_escape)" "$(printf '%s' "$disk_free_avg" | html_escape)"
printf ' <tr><td>Repo size (GiB)</td><td id="cur-repo-size" class="num" data-label="Current">%s</td><td id="min-repo-size" class="num" data-label="Min">%s</td><td id="max-repo-size" class="num" data-label="Max">%s</td><td id="avg-repo-size" class="num" data-label="Avg">%s</td></tr>\n' \
"$(printf '%s' "$repo_size" | html_escape)" "$(printf '%s' "$repo_size_min" | html_escape)" "$(printf '%s' "$repo_size_max" | html_escape)" "$(printf '%s' "$repo_size_avg" | html_escape)"
printf ' </tbody>\n'
printf ' </table>\n'
printf ' </div>\n'
printf ' <p class="meta"><span>CPU total:&nbsp;<strong id="info-cpu-count">%s</strong>&nbsp;&nbsp;·&nbsp;&nbsp;RAM total:&nbsp;<strong id="info-ram-total">%s</strong>&nbsp;&nbsp;·&nbsp;&nbsp;Disk total:&nbsp;<strong id="info-disk-total">%s</strong>&nbsp;&nbsp;·&nbsp;&nbsp;Updated:&nbsp;<span id="info-updated">%s</span>&nbsp;(updated every 60s)</span><span>(*: min/max/avg since script start)</span></p>\n' \
"$(printf '%s' "$cpu_count" | html_escape)" "$(printf '%s' "$ram_total" | html_escape)" "$(printf '%s' "$disk_total" | html_escape)" "$(printf '%s' "$updated" | html_escape)"
printf ' </main>\n'
printf ' <script>\n'
printf ' function fmt(v) { const n = parseFloat(v); return (!isNaN(n) && isFinite(n)) ? n.toFixed(2) : "—"; }\n'
printf ' function set(id, text) { const el = document.getElementById(id); if (el) el.textContent = text; }\n'
printf ' const rows = [\n'
printf ' { cur: "cpu_used", min: "cpu_used_min", max: "cpu_used_max", avg: "cpu_used_avg", curId: "cur-cpu-used", minId: "min-cpu-used", maxId: "max-cpu-used", avgId: "avg-cpu-used" },\n'
printf ' { cur: "cpu_idle", min: "cpu_idle_min", max: "cpu_idle_max", avg: "cpu_idle_avg", curId: "cur-cpu-idle", minId: "min-cpu-idle", maxId: "max-cpu-idle", avgId: "avg-cpu-idle" },\n'
printf ' { cur: "ram_used_gib", min: "ram_used_gib_min", max: "ram_used_gib_max", avg: "ram_used_gib_avg", curId: "cur-ram-used", minId: "min-ram-used", maxId: "max-ram-used", avgId: "avg-ram-used" },\n'
printf ' { cur: "ram_free_gib", min: "ram_free_gib_min", max: "ram_free_gib_max", avg: "ram_free_gib_avg", curId: "cur-ram-avail", minId: "min-ram-avail", maxId: "max-ram-avail", avgId: "avg-ram-avail" },\n'
printf ' { cur: "disk_used_gib", min: "disk_used_gib_min", max: "disk_used_gib_max", avg: "disk_used_gib_avg", curId: "cur-disk-used", minId: "min-disk-used", maxId: "max-disk-used", avgId: "avg-disk-used" },\n'
printf ' { cur: "disk_free_gib", min: "disk_free_gib_min", max: "disk_free_gib_max", avg: "disk_free_gib_avg", curId: "cur-disk-avail", minId: "min-disk-avail", maxId: "max-disk-avail", avgId: "avg-disk-avail" },\n'
printf ' { cur: "repo_size_gib", min: "repo_size_gib_min", max: "repo_size_gib_max", avg: "repo_size_gib_avg", curId: "cur-repo-size", minId: "min-repo-size", maxId: "max-repo-size", avgId: "avg-repo-size" }\n'
printf ' ];\n'
printf ' async function refresh() {\n'
printf ' try {\n'
printf ' const r = await fetch("system.json?ts=" + Date.now(), { cache: "no-store" });\n'
printf ' if (!r.ok) return;\n'
printf ' const d = await r.json();\n'
printf ' if (!d || !d.generation) return;\n'
printf ' for (const row of rows) {\n'
printf ' set(row.curId, fmt(d[row.cur]));\n'
printf ' set(row.minId, fmt(d[row.min]));\n'
printf ' set(row.maxId, fmt(d[row.max]));\n'
printf ' set(row.avgId, fmt(d[row.avg]));\n'
printf ' }\n'
printf ' if (d.cpu_count != null) set("info-cpu-count", d.cpu_count + " cores");\n'
printf ' if (d.ram_total_gib != null) set("info-ram-total", fmt(d.ram_total_gib) + " GiB");\n'
printf ' if (d.disk_total_gib != null) set("info-disk-total", fmt(d.disk_total_gib) + " GiB");\n'
printf ' if (d.timestamp) {\n'
printf ' const dt = new Date(d.timestamp);\n'
printf ' const pad = (n) => n.toString().padStart(2, "0");\n'
printf ' set("info-updated", pad(dt.getHours()) + ":" + pad(dt.getMinutes()) + ":" + pad(dt.getSeconds()));\n'
printf ' } else {\n'
printf ' set("info-updated", "—");\n'
printf ' }\n'
printf ' } catch (e) { /* ignore network errors */ }\n'
printf ' }\n'
printf ' let refreshTimer = null;\n'
printf ' function startAutoRefresh() {\n'
printf ' if (refreshTimer === null) refreshTimer = window.setInterval(refresh, 60000);\n'
printf ' }\n'
printf ' function stopAutoRefresh() {\n'
printf ' if (refreshTimer !== null) window.clearInterval(refreshTimer);\n'
printf ' refreshTimer = null;\n'
printf ' }\n'
printf ' function resumeAutoRefresh() {\n'
printf ' refresh();\n'
printf ' window.setTimeout(refresh, 250);\n'
printf ' startAutoRefresh();\n'
printf ' }\n'
printf ' function resumeFromNavigation(event) {\n'
printf ' if (event && event.persisted) {\n'
printf ' window.location.reload();\n'
printf ' return;\n'
printf ' }\n'
printf ' resumeAutoRefresh();\n'
printf ' }\n'
printf ' window.addEventListener("pageshow", resumeFromNavigation);\n'
printf ' document.addEventListener("visibilitychange", () => {\n'
printf ' if (document.visibilityState === "visible") resumeAutoRefresh();\n'
printf ' else stopAutoRefresh();\n'
printf ' });\n'
printf ' window.addEventListener("pagehide", stopAutoRefresh);\n'
printf ' resumeAutoRefresh();\n'
printf ' </script>\n'
} >>"$index_file"
write_html_footer "$index_file"
{
printf '</body>\n'
printf '</html>\n'
} >>"$index_file"
}
write_env_page() {
local artifacts_root index_file page_title env_text
artifacts_root=$(build_artifacts_root)
write_html_favicon "$artifacts_root" || return 1
index_file="$artifacts_root/env.html"
page_title="GitTally [$(repository_simple_name)] - Env"
mkdir -p "$artifacts_root" || return 1
env_text=$(print_env)
{
printf '<!doctype html>\n'
printf '<html lang="en">\n'
printf '<head>\n'
printf ' <meta charset="utf-8">\n'
printf ' <meta name="viewport" content="width=device-width, initial-scale=1">\n'
printf ' <meta http-equiv="Cache-Control" content="no-store">\n'
printf ' <title>%s</title>\n' "$(printf '%s' "$page_title" | html_escape)"
} >"$index_file"
write_html_favicon_links "$index_file"
{
printf ' <style>\n'
printf ' :root { color-scheme: light dark; --bg: #f6f8fa; --panel: #ffffff; --text: #1f2937; --muted: #6b7280; --border: #d7dde5; --row: #f9fafb; --link: #155eef; }\n'
printf ' @media (prefers-color-scheme: dark) { :root { --bg: #111827; --panel: #1f2937; --text: #f3f4f6; --muted: #9ca3af; --border: #374151; --row: #182235; --link: #93c5fd; } }\n'
printf ' * { box-sizing: border-box; }\n'
printf ' body { margin: 0; background: var(--bg); color: var(--text); font: 14px/1.45 system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; }\n'
printf ' main { width: min(1180px, calc(100%% - 32px)); margin: 32px auto; }\n'
printf ' h1 { display: flex; align-items: center; gap: 10px; margin: 0 0 18px; font-size: 28px; font-weight: 700; }\n'
printf ' h1 img { width: 32px; height: 32px; flex: none; }\n'
printf ' .title-home { display: inline-flex; flex: none; }\n'
printf ' a { color: var(--link); font-weight: 650; text-decoration: none; }\n'
printf ' a:hover { text-decoration: underline; }\n'
printf ' .view-row { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin: 0 0 18px; }\n'
printf ' .view-toggle { display: inline-flex; gap: 0; border: 1px solid var(--border); border-radius: 8px; overflow: hidden; background: var(--panel); }\n'
printf ' .view-toggle a, .view-toggle span { display: inline-flex; min-width: 88px; justify-content: center; padding: 7px 12px; font-weight: 700; }\n'
printf ' .view-toggle span { background: var(--link); color: white; }\n'
printf ' .view-toggle a { color: var(--link); }\n'
printf ' .view-toggle a:hover { background: color-mix(in srgb, var(--link) 8%%, transparent); text-decoration: none; }\n'
printf ' .table-wrap { overflow-x: auto; border: 1px solid var(--border); border-radius: 8px; background: var(--panel); box-shadow: 0 12px 28px rgb(15 23 42 / 0.08); }\n'
printf ' pre { margin: 0; padding: 16px 20px; font: 13px/1.6 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; white-space: pre-wrap; word-break: break-all; }\n'
printf ' .site-footer { width: min(1180px, calc(100%% - 32px)); margin: 24px auto 32px; color: var(--muted); font-size: 12px; }\n'
printf ' @media (max-width: 680px) { body { font-size: 14px; } main { margin: 16px auto; } h1 { font-size: 22px; } .view-toggle a, .view-toggle span { min-width: 0; padding: 6px 9px; font-size: 13px; } pre { font-size: 13px; padding: 12px; } }\n'
printf ' </style>\n'
printf '</head>\n'
printf '<body>\n'
printf ' <main>\n'
printf ' <h1><a class="title-home" href="index.html" aria-label="Open latest builds"><img src="favicon.svg" alt="" aria-hidden="true"></a>%s</h1>\n' "$(printf '%s' "$page_title" | html_escape)"
} >>"$index_file"
write_build_artifact_view_toggle "$index_file" Env
{
printf ' <div class="table-wrap">\n'
printf ' <pre>%s</pre>\n' "$(printf '%s' "$env_text" | html_escape)"
printf ' </div>\n'
printf ' </main>\n'
} >>"$index_file"
write_html_footer "$index_file"
{
printf '</body>\n'
printf '</html>\n'
} >>"$index_file"
}
start_resource_monitor() {
[ -f /proc/stat ] && [ -f /proc/meminfo ] || return 0
local artifacts_root pid_file old_pid
artifacts_root=$(build_artifacts_root)
pid_file="$artifacts_root/system_monitor.pid"
if [ -f "$pid_file" ]; then
old_pid=$(cat "$pid_file" 2>/dev/null)
if [ -n "$old_pid" ] && kill -0 "$old_pid" 2>/dev/null; then
kill "$old_pid" 2>/dev/null || true
fi
rm -f "$pid_file"
fi
rm -f "$artifacts_root/system_state.dat" "$artifacts_root/system.json"
(
local cpu_count prev_total prev_idle
cpu_count=$(nproc)
read -r prev_total prev_idle < <(
awk '/^cpu / {idle=$5; total=0; for(i=2;i<=NF;i++) total+=$i; print total, idle; exit}' /proc/stat
)
while true; do
local total idle total_diff idle_diff cpu_used cpu_idle
local ram_total_gib ram_used_gib ram_free_gib timestamp
local disk_total_gib disk_used_gib disk_free_gib
local artifacts_root system_file state_file
read -r total idle < <(
awk '/^cpu / {idle=$5; total=0; for(i=2;i<=NF;i++) total+=$i; print total, idle; exit}' /proc/stat
)
read -r ram_total_gib ram_used_gib ram_free_gib < <(
LC_ALL=C awk '/^MemTotal:/ {total=$2} /^MemAvailable:/ {avail=$2}
END { used=total-avail; printf "%.2f %.2f %.2f\n", total/1048576, used/1048576, avail/1048576 }' \
/proc/meminfo
)
read -r disk_total_gib disk_used_gib disk_free_gib < <(
LC_ALL=C df -Pk . | awk 'NR > 1 { t += $(NF-4); u += $(NF-3); a += $(NF-2) } END { printf "%.2f %.2f %.2f\n", t/1048576, u/1048576, a/1048576 }'
)
read -r repo_size_gib < <(
du -sk . | awk '{printf "%.4f\n", $1/1048576}'
)
total_diff=$((total - prev_total))
idle_diff=$((idle - prev_idle))
if [ "$total_diff" -gt 0 ]; then
cpu_used=$(awk "BEGIN {printf \"%.2f\", $cpu_count * ($total_diff - $idle_diff) / $total_diff}")
cpu_idle=$(awk "BEGIN {printf \"%.2f\", $cpu_count - $cpu_used}")
else
cpu_used="0.00"
cpu_idle=$(printf "%.2f" "$cpu_count")
fi
prev_total=$total
prev_idle=$idle
artifacts_root=$(build_artifacts_root)
system_file="$artifacts_root/system.json"
state_file="$artifacts_root/system_state.dat"
mkdir -p "$artifacts_root" || continue
timestamp=$(date -Iseconds)
LC_ALL=C awk -v ts="$timestamp" -v gen="$monitor_generation" -v cc="$cpu_count" -v rtg="$ram_total_gib" \
-v cu="$cpu_used" -v ci="$cpu_idle" -v ru="$ram_used_gib" -v rf="$ram_free_gib" \
-v dtg="$disk_total_gib" -v du="$disk_used_gib" -v df="$disk_free_gib" \
-v rs="$repo_size_gib" -v sf="$state_file" \
'BEGIN {
n = 0
cu_min = cu; cu_max = cu; cu_sum = 0
ci_min = ci; ci_max = ci; ci_sum = 0
ru_min = ru; ru_max = ru; ru_sum = 0
rf_min = rf; rf_max = rf; rf_sum = 0
du_min = du; du_max = du; du_sum = 0
df_min = df; df_max = df; df_sum = 0
rs_min = rs; rs_max = rs; rs_sum = 0
if ((getline line < sf) > 0) {
nf = split(line, f, " ")
if (nf >= 22) {
n = f[1]+0
cu_min = f[2]+0; cu_max = f[3]+0; cu_sum = f[4]+0
ci_min = f[5]+0; ci_max = f[6]+0; ci_sum = f[7]+0
ru_min = f[8]+0; ru_max = f[9]+0; ru_sum = f[10]+0
rf_min = f[11]+0; rf_max = f[12]+0; rf_sum = f[13]+0
du_min = f[14]+0; du_max = f[15]+0; du_sum = f[16]+0
df_min = f[17]+0; df_max = f[18]+0; df_sum = f[19]+0
rs_min = f[20]+0; rs_max = f[21]+0; rs_sum = f[22]+0
} else if (nf >= 19) {
n = f[1]+0
cu_min = f[2]+0; cu_max = f[3]+0; cu_sum = f[4]+0
ci_min = f[5]+0; ci_max = f[6]+0; ci_sum = f[7]+0
ru_min = f[8]+0; ru_max = f[9]+0; ru_sum = f[10]+0
rf_min = f[11]+0; rf_max = f[12]+0; rf_sum = f[13]+0
}
}
close(sf)
n++
if (n == 1 || cu < cu_min) cu_min = cu
if (n == 1 || cu > cu_max) cu_max = cu
cu_sum += cu
if (n == 1 || ci < ci_min) ci_min = ci
if (n == 1 || ci > ci_max) ci_max = ci
ci_sum += ci
if (n == 1 || ru < ru_min) ru_min = ru
if (n == 1 || ru > ru_max) ru_max = ru
ru_sum += ru
if (n == 1 || rf < rf_min) rf_min = rf
if (n == 1 || rf > rf_max) rf_max = rf
rf_sum += rf
if (n == 1 || du < du_min) du_min = du
if (n == 1 || du > du_max) du_max = du
du_sum += du
if (n == 1 || df < df_min) df_min = df
if (n == 1 || df > df_max) df_max = df
df_sum += df
if (n == 1 || rs < rs_min) rs_min = rs
if (n == 1 || rs > rs_max) rs_max = rs
rs_sum += rs
printf "%d %.4f %.4f %.4f %.4f %.4f %.4f %.4f %.4f %.4f %.4f %.4f %.4f %.4f %.4f %.4f %.4f %.4f %.4f %.4f %.4f %.4f\n",
n, cu_min, cu_max, cu_sum, ci_min, ci_max, ci_sum,
ru_min, ru_max, ru_sum, rf_min, rf_max, rf_sum,
du_min, du_max, du_sum, df_min, df_max, df_sum,
rs_min, rs_max, rs_sum > sf
close(sf)
printf "{\"timestamp\":\"%s\",\"generation\":\"%s\",\"cpu_count\":%d,\"sample_count\":%d,", ts, gen, cc, n
printf "\"cpu_used\":%.2f,\"cpu_used_min\":%.2f,\"cpu_used_max\":%.2f,\"cpu_used_avg\":%.2f,", cu, cu_min, cu_max, cu_sum/n
printf "\"cpu_idle\":%.2f,\"cpu_idle_min\":%.2f,\"cpu_idle_max\":%.2f,\"cpu_idle_avg\":%.2f,", ci, ci_min, ci_max, ci_sum/n
printf "\"ram_total_gib\":%.2f,", rtg
printf "\"ram_used_gib\":%.2f,\"ram_used_gib_min\":%.2f,\"ram_used_gib_max\":%.2f,\"ram_used_gib_avg\":%.2f,", ru, ru_min, ru_max, ru_sum/n
printf "\"ram_free_gib\":%.2f,\"ram_free_gib_min\":%.2f,\"ram_free_gib_max\":%.2f,\"ram_free_gib_avg\":%.2f,", rf, rf_min, rf_max, rf_sum/n
printf "\"disk_total_gib\":%.2f,", dtg
printf "\"disk_used_gib\":%.2f,\"disk_used_gib_min\":%.2f,\"disk_used_gib_max\":%.2f,\"disk_used_gib_avg\":%.2f,", du, du_min, du_max, du_sum/n
printf "\"disk_free_gib\":%.2f,\"disk_free_gib_min\":%.2f,\"disk_free_gib_max\":%.2f,\"disk_free_gib_avg\":%.2f,", df, df_min, df_max, df_sum/n
printf "\"repo_size_gib\":%.2f,\"repo_size_gib_min\":%.2f,\"repo_size_gib_max\":%.2f,\"repo_size_gib_avg\":%.2f}\n", rs, rs_min, rs_max, rs_sum/n
}' > "${system_file}.tmp" && mv "${system_file}.tmp" "${system_file}" && write_system_page || true
sleep 60
done
) &
echo $! > "$pid_file"
}
write_artifacts_root_index() {
write_artifacts_root_index_page latest || return 1
write_artifacts_root_index_page branches || return 1
write_artifacts_root_index_page history || return 1
if [ -z "${active_build_branch:-}" ]; then
write_current_build_page
fi
write_system_page || return 1
}
detect_artifact_http_server_host() {
local host
if [ "$artifact_http_server_bind_address" != "0.0.0.0" ]; then
echo "$artifact_http_server_bind_address"
return 0
fi
if command -v ip >/dev/null 2>&1; then
host=$(ip route get 1.1.1.1 2>/dev/null | sed -n 's/.* src \([0-9.]*\).*/\1/p' | head -n 1)
if [ -n "$host" ]; then
echo "$host"
return 0
fi
fi
if command -v hostname >/dev/null 2>&1; then
host=$(hostname -I 2>/dev/null | awk '{ for (i = 1; i <= NF; i++) if ($i ~ /^[0-9.]+$/) { print $i; exit } }')
if [ -n "$host" ]; then
echo "$host"
return 0
fi
fi
echo "127.0.0.1"
}
default_artifact_public_base_url() {
local port="$1"
local url_host
url_host=$(detect_artifact_http_server_host)
echo "http://$url_host:$port/"
}
start_artifact_http_server_process() {
local port="$1"
local bind_address="$2"
local directory="$3"
local cancel_request_file="$4"
local cancel_token_file="$5"
local results_file="$6"
GITTALLY_GITEA_BASE_URL="$gitea_base_url" \
GITTALLY_GITEA_OWNER="$gitea_owner" \
GITTALLY_GITEA_REPO="$gitea_repo" \
GITTALLY_GITEA_TOKEN="$gitea_token" \
GITTALLY_GITEA_STATUS_CONTEXT="$gitea_status_context" \
GITTALLY_GITEA_DELETED_STATUS_DESCRIPTION="$(gitea_deleted_status_description)" \
GITTALLY_SCRIPT_VERSION="$script_version" \
GITTALLY_IMPRESSUM_URL="$impressum_url" \
python3 -c '
import functools
import datetime
import hashlib
import html
import http.server
import json
import os
import re
import sys
import urllib.parse
import urllib.request
port = int(sys.argv[1])
bind_address = sys.argv[2]
directory = sys.argv[3]
cancel_request_file = sys.argv[4]
cancel_token_file = sys.argv[5]
results_file = sys.argv[6]
gitea_base_url = os.environ.get("GITTALLY_GITEA_BASE_URL", "")
gitea_owner = os.environ.get("GITTALLY_GITEA_OWNER", "")
gitea_repo = os.environ.get("GITTALLY_GITEA_REPO", "")
gitea_token = os.environ.get("GITTALLY_GITEA_TOKEN", "")
gitea_status_context = os.environ.get("GITTALLY_GITEA_STATUS_CONTEXT", "")
gitea_deleted_status_description = os.environ.get("GITTALLY_GITEA_DELETED_STATUS_DESCRIPTION", "Build status deleted")
script_version = os.environ.get("GITTALLY_SCRIPT_VERSION", "")
impressum_url = os.environ.get("GITTALLY_IMPRESSUM_URL", "")
class ArtifactRequestHandler(http.server.SimpleHTTPRequestHandler):
extensions_map = {
**http.server.SimpleHTTPRequestHandler.extensions_map,
".log": "text/plain; charset=utf-8",
".sh": "text/x-shellscript; charset=utf-8",
}
no_store_suffixes = (".html", ".json", ".log")
def is_control_path(self, request_path, control_path):
request_path = request_path.rstrip("/") or "/"
return request_path == control_path or request_path.endswith(control_path)
def end_headers(self):
request_path = urllib.parse.urlparse(self.path).path
if request_path.endswith(self.no_store_suffixes) or request_path.startswith("/control/"):
self.send_header("Cache-Control", "no-store, max-age=0")
self.send_header("Pragma", "no-cache")
self.send_header("Expires", "0")
super().end_headers()
def do_GET(self):
request = urllib.parse.urlparse(self.path)
if self.is_control_path(request.path, "/control/status"):
self.handle_status(urllib.parse.parse_qs(request.query))
return
if self.handle_artifact_index_request(request.path):
return
super().do_GET()
def handle_artifact_index_request(self, request_path):
request_path = urllib.parse.unquote(request_path)
match = re.fullmatch(r"/?branches/([A-Za-z0-9._-]+)/index\.html", request_path)
if not match:
return False
artifact_key = match.group(1)
artifact_dir = os.path.join(directory, "branches", artifact_key)
content = self.read_artifact_index_content(artifact_dir)
if content is None:
self.send_error(404)
return True
branch = self.branch_for_artifact_key(artifact_key)
payload = self.render_artifact_index_page(branch, content).encode("utf-8")
self.send_response(200)
self.send_header("Content-Type", "text/html; charset=utf-8")
self.send_header("Content-Length", str(len(payload)))
self.end_headers()
self.wfile.write(payload)
return True
def read_artifact_index_content(self, artifact_dir):
content_file = os.path.join(artifact_dir, "artifact-index-content.html")
try:
with open(content_file, encoding="utf-8") as content_input:
return content_input.read()
except FileNotFoundError:
pass
old_index_file = os.path.join(artifact_dir, "index.html")
try:
with open(old_index_file, encoding="utf-8") as index_input:
old_index = index_input.read()
except FileNotFoundError:
return None
article_match = re.search(r"<article\b[^>]*>.*?</article>", old_index, re.IGNORECASE | re.DOTALL)
if article_match:
return article_match.group(0)
main_match = re.search(r"<main\b[^>]*>(.*?)</main>", old_index, re.IGNORECASE | re.DOTALL)
if main_match:
return re.sub(
r"\s*<h1\b[^>]*>.*?</h1>\s*",
"",
main_match.group(1),
count=1,
flags=re.IGNORECASE | re.DOTALL,
).strip()
return "<article><p>Could not extract artifact index content from the stored page.</p></article>"
def branch_for_artifact_key(self, artifact_key):
try:
with open(results_file, encoding="utf-8") as results_input:
for line in results_input:
fields = line.rstrip("\n").split("\t")
while len(fields) < 6:
fields.append("")
branch, _commit, _status, _timestamp, _duration, stored_artifact_key = fields[:6]
if stored_artifact_key == artifact_key:
return branch
except FileNotFoundError:
pass
return artifact_key
def render_artifact_index_page(self, branch, content):
escaped_branch = html.escape(branch)
escaped_branch_title = html.escape(branch, quote=True)
branch_url = self.gitea_branch_web_url(branch)
if branch_url:
branch_title_html = f"""<span class="title-branch-wrap"><a class="title-branch" href="{html.escape(branch_url, quote=True)}" target="_blank" rel="noopener noreferrer" title="{escaped_branch_title}"><span class="title-branch-text">{escaped_branch}</span><span class="title-jump" aria-hidden="true">↗</span></a><button class="copy-button" type="button" data-copy="{escaped_branch_title}" title="Copy branch name" aria-label="Copy branch name">⧉</button></span>"""
else:
branch_title_html = f"""<span class="title-branch-wrap"><span class="title-branch" title="{escaped_branch_title}"><span class="title-branch-text">{escaped_branch}</span></span><button class="copy-button" type="button" data-copy="{escaped_branch_title}" title="Copy branch name" aria-label="Copy branch name">⧉</button></span>"""
escaped_version = html.escape(script_version)
escaped_impressum_url = html.escape(impressum_url, quote=True)
return f"""<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Build artifacts: {escaped_branch}</title>
<link rel="icon" href="../../favicon.svg" type="image/svg+xml">
<link rel="shortcut icon" href="../../favicon.svg" type="image/svg+xml">
<style>
:root {{ color-scheme: light dark; --bg: #f6f8fa; --panel: #ffffff; --text: #1f2937; --muted: #6b7280; --border: #d7dde5; --link: #155eef; }}
@media (prefers-color-scheme: dark) {{ :root {{ --bg: #111827; --panel: #1f2937; --text: #f3f4f6; --muted: #9ca3af; --border: #374151; --link: #93c5fd; }} }}
* {{ box-sizing: border-box; }}
body {{ margin: 0; background: var(--bg); color: var(--text); font: 14px/1.55 system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; }}
main {{ width: min(1180px, calc(100% - 32px)); margin: 32px auto; }}
h1 {{ display: flex; align-items: center; gap: 10px; min-width: 0; margin: 0 0 18px; font-size: 28px; font-weight: 700; white-space: nowrap; }}
h1 img {{ width: 32px; height: 32px; flex: none; }}
.title-home {{ display: inline-flex; flex: none; }}
.title-prefix {{ flex: none; }}
.title-branch-wrap {{ display: inline-flex; align-items: center; gap: 5px; min-width: 0; }}
.title-branch {{ display: inline-flex; align-items: center; gap: 4px; min-width: 0; overflow: hidden; white-space: nowrap; }}
.title-branch-text {{ min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }}
.title-jump {{ flex: none; color: var(--muted); font-size: 0.72em; }}
h2 {{ margin: 26px 0 12px; font-size: 18px; }}
article {{ border: 1px solid var(--border); border-radius: 8px; background: var(--panel); padding: 22px; }}
article ul {{ margin: 0 0 16px 20px; padding: 0; }}
article li {{ margin: 0 0 7px; }}
a {{ color: var(--link); font-weight: 650; text-decoration: none; }}
a:hover {{ text-decoration: underline; }}
h1 .title-branch {{ color: inherit; font-weight: inherit; text-decoration: none; }}
h1 .title-branch:hover {{ color: var(--link); text-decoration: underline; }}
h1 .title-branch:hover .title-jump {{ color: var(--link); }}
.copy-button {{ appearance: none; display: inline-flex; align-items: center; justify-content: center; width: 22px; height: 22px; border: 1px solid transparent; border-radius: 5px; background: transparent; color: var(--muted); font: 14px/1 system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; cursor: pointer; }}
.copy-button:hover {{ border-color: var(--border); background: color-mix(in srgb, var(--link) 8%, transparent); color: var(--link); }}
.copy-button.is-copied {{ color: #166534; }}
.site-footer {{ width: min(1180px, calc(100% - 32px)); margin: 24px auto 32px; color: var(--muted); font-size: 12px; }}
</style>
</head>
<body>
<main>
<h1><a class="title-home" href="../../index.html" aria-label="Open latest builds"><img src="../../favicon.svg" alt="" aria-hidden="true"></a><span class="title-prefix">Build artifacts:</span> {branch_title_html}</h1>
{content}
</main>
<footer class="site-footer"><a href="../../about.html"><strong><em>gitTally v{escaped_version}</em></strong></a> - (c) <a href="https://michael.hoennig.de" target="_blank" rel="noopener noreferrer">Michael Hönnig</a>, 2026 - Licensed under the <a href="../../license.html">MIT License</a> - <a href="{escaped_impressum_url}" target="_blank" rel="noopener noreferrer">Impressum (Legal Disclosure)</a></footer>
<script>
async function copyToClipboard(button) {{
const text = button.dataset.copy || "";
if (!text) return;
try {{
if (navigator.clipboard && window.isSecureContext) {{
await navigator.clipboard.writeText(text);
}} else {{
const input = document.createElement("textarea");
input.value = text;
input.style.position = "fixed";
input.style.left = "-9999px";
document.body.appendChild(input);
input.focus();
input.select();
document.execCommand("copy");
input.remove();
}}
button.classList.add("is-copied");
window.setTimeout(() => button.classList.remove("is-copied"), 900);
}} catch (error) {{
return;
}}
}}
document.addEventListener("click", (event) => {{
const button = event.target.closest(".copy-button[data-copy]");
if (!button) return;
event.preventDefault();
copyToClipboard(button);
}});
</script>
</body>
</html>
"""
def gitea_branch_web_url(self, branch):
if not (gitea_base_url and gitea_owner and gitea_repo):
return ""
return (
gitea_base_url.rstrip("/") + "/" +
urllib.parse.quote(gitea_owner, safe="") + "/" +
urllib.parse.quote(gitea_repo, safe="") + "/src/branch/" +
urllib.parse.quote(branch, safe="/")
)
def do_POST(self):
request_path = urllib.parse.urlparse(self.path).path
if self.is_control_path(request_path, "/control/cancel"):
self.handle_cancel()
elif self.is_control_path(request_path, "/control/restart"):
body = self.read_form_body()
if body is None:
return
self.handle_restart(urllib.parse.parse_qs(body))
elif self.is_control_path(request_path, "/control/delete"):
body = self.read_form_body()
if body is None:
return
self.handle_delete(urllib.parse.parse_qs(body))
else:
self.send_error(404)
return
def read_form_body(self):
content_length = int(self.headers.get("Content-Length", "0"))
if content_length > 4096:
self.send_error(413)
return None
return self.rfile.read(content_length).decode("utf-8")
def handle_status(self, query):
commit = query.get("commit", [""])[0]
local_status = query.get("local_status", [""])[0]
if not re.fullmatch(r"[0-9a-fA-F]{7,40}", commit):
self.send_error(400)
return
if not re.fullmatch(r"[A-Za-z_-]+", local_status):
self.send_error(400)
return
status = self.read_gitea_build_status(commit) or local_status
payload = json.dumps({"status": status}).encode("utf-8")
self.send_response(200)
self.send_header("Content-Type", "application/json; charset=utf-8")
self.send_header("Content-Length", str(len(payload)))
self.end_headers()
self.wfile.write(payload)
def read_gitea_build_status(self, commit):
if not (gitea_base_url and gitea_owner and gitea_repo and gitea_token and gitea_status_context):
return None
owner = urllib.parse.quote(gitea_owner, safe="")
repo = urllib.parse.quote(gitea_repo, safe="")
status_url = (
gitea_base_url.rstrip("/") + "/api/v1/repos/" + owner + "/" + repo +
"/commits/" + commit + "/statuses?sort=recentupdate"
)
request = urllib.request.Request(
status_url,
headers={"Authorization": "token " + gitea_token},
method="GET",
)
try:
with urllib.request.urlopen(request, timeout=10) as response:
if response.status < 200 or response.status >= 300:
return None
statuses = json.loads(response.read().decode("utf-8"))
except Exception:
return None
for status in statuses:
if status.get("context") != gitea_status_context:
continue
if status.get("description") == gitea_deleted_status_description:
return None
state = status.get("state", "")
if state == "success":
return "success"
if state in ("failure", "error", "warning"):
return "failed"
if state == "pending":
return "running"
return None
return None
def handle_cancel(self):
body = self.read_form_body()
if body is None:
return
submitted_token = urllib.parse.parse_qs(body).get("token", [""])[0]
try:
with open(cancel_token_file, encoding="utf-8") as token_input:
expected_token = token_input.readline().strip()
except FileNotFoundError:
expected_token = ""
if not expected_token or submitted_token != expected_token:
self.send_error(403)
return
with open(cancel_request_file, "w", encoding="utf-8") as request_output:
request_output.write("cancel\n")
self.send_response(202)
self.send_header("Content-Type", "text/plain; charset=utf-8")
self.end_headers()
self.wfile.write(b"Cancellation requested.\n")
def handle_restart(self, form):
branch = form.get("branch", [""])[0]
commit = form.get("commit", [""])[0]
return_to = form.get("return_to", ["index.html"])[0]
if not branch or "\n" in branch or "\r" in branch:
self.send_error(400)
return
if not commit:
commit = "0000000"
elif not re.fullmatch(r"[0-9a-fA-F]{7,40}", commit):
self.send_error(400)
return
if return_to not in ("index.html", "branches.html"):
return_to = "index.html"
timestamp = datetime.datetime.now().astimezone().isoformat(timespec="seconds")
branch_key = re.sub(r"[^A-Za-z0-9._-]", "_", branch)
branch_hash = hashlib.sha256(branch.encode("utf-8")).hexdigest()[:12]
timestamp_key = re.sub(r"[^A-Za-z0-9._-]", "_", timestamp)
artifact_key = branch_key + "-" + branch_hash + "-restart-" + timestamp_key
os.makedirs(os.path.dirname(results_file), exist_ok=True)
with open(results_file, "a", encoding="utf-8") as results_output:
results_output.write("\t".join([branch, commit, "pending", timestamp, "", artifact_key]) + "\n")
self.mark_latest_page_pending(branch)
self.send_response(303)
self.send_header("Location", "/" + return_to)
self.send_header("Content-Type", "text/plain; charset=utf-8")
self.end_headers()
self.wfile.write(b"Restart requested.\n")
def handle_delete(self, form):
branch = form.get("branch", [""])[0]
commit = form.get("commit", [""])[0]
artifact_key = form.get("artifact_key", [""])[0]
return_to = form.get("return_to", ["index.html"])[0]
if not branch or "\n" in branch or "\r" in branch:
self.send_error(400)
return
if not re.fullmatch(r"[0-9a-fA-F]{7,40}", commit):
self.send_error(400)
return
if not re.fullmatch(r"[A-Za-z0-9._-]+", artifact_key):
self.send_error(400)
return
if return_to not in ("index.html", "branches.html", "history.html"):
return_to = "index.html"
if self.delete_stored_status(branch, commit, artifact_key):
self.publish_deleted_gitea_status(commit)
self.remove_visible_status(branch, commit, artifact_key)
self.send_response(303)
self.send_header("Location", "/" + return_to)
self.send_header("Content-Type", "text/plain; charset=utf-8")
self.end_headers()
self.wfile.write(b"Stored status deleted.\n")
def delete_stored_status(self, wanted_branch, wanted_commit, wanted_artifact_key):
removed = False
kept_lines = []
try:
with open(results_file, encoding="utf-8") as results_input:
lines = results_input.readlines()
except FileNotFoundError:
return False
for line in lines:
fields = line.rstrip("\n").split("\t")
while len(fields) < 6:
fields.append("")
branch, commit, status, timestamp, duration, artifact_key = fields[:6]
if branch == wanted_branch and commit == wanted_commit and artifact_key == wanted_artifact_key:
removed = True
continue
kept_lines.append(line)
if removed:
os.makedirs(os.path.dirname(results_file), exist_ok=True)
temp_file = results_file + ".delete"
with open(temp_file, "w", encoding="utf-8") as results_output:
results_output.writelines(kept_lines)
os.replace(temp_file, results_file)
return removed
def publish_deleted_gitea_status(self, commit):
if not (gitea_base_url and gitea_owner and gitea_repo and gitea_token and gitea_status_context):
return
payload = json.dumps({
"state": "warning",
"context": gitea_status_context,
"description": gitea_deleted_status_description,
}).encode("utf-8")
owner = urllib.parse.quote(gitea_owner, safe="")
repo = urllib.parse.quote(gitea_repo, safe="")
status_url = gitea_base_url.rstrip("/") + "/api/v1/repos/" + owner + "/" + repo + "/statuses/" + commit
request = urllib.request.Request(
status_url,
data=payload,
headers={
"Authorization": "token " + gitea_token,
"Content-Type": "application/json",
},
method="POST",
)
try:
urllib.request.urlopen(request, timeout=10).close()
except Exception:
print("WARNING: could not publish deleted Gitea build status for " + commit + ".", file=sys.stderr)
def remove_visible_status(self, branch, commit, artifact_key):
escaped_artifact_key = html.escape(artifact_key, quote=True)
row_pattern = re.compile(
r"\s*<tr\b[^>]*\bdata-artifact-key=\"" + re.escape(escaped_artifact_key) +
r"\"[^>]*>.*?</tr>\n?",
re.DOTALL,
)
branch_row = (
" <tr class=\"status-unknown\" data-branch=\"" + html.escape(branch, quote=True) +
"\" data-artifact-key=\"\" data-commit=\"" + html.escape(commit, quote=True) +
"\" data-local-status=\"unknown\"><td><span class=\"status status-unknown\">unknown</span></td>" +
"<td class=\"branch\">" + html.escape(branch) + "</td><td><code>" + html.escape(commit[:12]) +
"</code></td><td></td><td></td><td></td><td>n/a</td><td class=\"actions-cell\"><div class=\"actions\">" +
"<form class=\"action-form\" method=\"post\" action=\"/control/restart\"><input type=\"hidden\" name=\"branch\" value=\"" +
html.escape(branch, quote=True) + "\"><input type=\"hidden\" name=\"commit\" value=\"" +
html.escape(commit, quote=True) + "\"><input type=\"hidden\" name=\"return_to\" value=\"branches.html\"><button class=\"action-button\" type=\"submit\" title=\"Restart build\" aria-label=\"Restart build\">↻</button></form>" +
"</div></td></tr>\n"
)
for page_name in ("index.html", "branches.html", "history.html"):
page_file = os.path.join(directory, page_name)
try:
with open(page_file, encoding="utf-8") as page_input:
page_html = page_input.read()
except FileNotFoundError:
continue
if page_name == "branches.html":
page_html = row_pattern.sub(branch_row, page_html, count=1)
else:
page_html = row_pattern.sub("", page_html, count=1)
page_html = re.sub(
r"(<tbody>\n)\s*(</tbody>)",
r"""\1 <tr><td class="empty" colspan="8">No builds archived yet.</td></tr>\n \2""",
page_html,
)
with open(page_file, "w", encoding="utf-8") as page_output:
page_output.write(page_html)
def mark_latest_page_pending(self, branch):
escaped_branch = html.escape(branch, quote=True)
row_pattern = re.compile(
r"(<tr class=\")status-[^\"]*(\" data-branch=\"" + re.escape(escaped_branch) +
r"\" data-artifact-key=\"[^\"]*\"><td><span class=\"status )status-[^\"]*(\">)[^<]*(</span>)"
)
for page_name in ("index.html", "branches.html"):
page_file = os.path.join(directory, page_name)
try:
with open(page_file, encoding="utf-8") as page_input:
page_html = page_input.read()
except FileNotFoundError:
continue
page_html = row_pattern.sub(r"\1status-pending\2status-pending\3pending\4", page_html, count=1)
with open(page_file, "w", encoding="utf-8") as page_output:
page_output.write(page_html)
handler_class = functools.partial(ArtifactRequestHandler, directory=directory)
server = http.server.ThreadingHTTPServer((bind_address, port), handler_class)
try:
server.serve_forever()
finally:
server.server_close()
' "$port" "$bind_address" "$directory" "$cancel_request_file" "$cancel_token_file" "$results_file"
}
start_artifact_http_server() {
local artifacts_root
local results_file
local port
local max_port
local url_host
local public_base_url
if [ "$use_artifact_http_server" != true ]; then
return 0
fi
if ! command -v python3 >/dev/null 2>&1; then
echo "WARNING: cannot start artifact HTTP server because python3 is not in PATH." >&2
return 0
fi
if ! [[ "$artifact_http_server_port" =~ ^[0-9]+$ ]] ||
[ "$artifact_http_server_port" -lt 1 ] ||
[ "$artifact_http_server_port" -gt 65535 ]; then
echo "WARNING: invalid GITTALLY_ARTIFACT_SERVER_PORT: $artifact_http_server_port" >&2
return 0
fi
artifacts_root=$(build_artifacts_root)
results_file=$(build_results_file)
write_artifacts_root_index || {
echo "WARNING: could not write artifact index." >&2
return 0
}
port=$artifact_http_server_port
max_port=$((artifact_http_server_port + 20))
if [ "$max_port" -gt 65535 ]; then
max_port=65535
fi
while [ "$port" -le "$max_port" ]; do
start_artifact_http_server_process "$port" "$artifact_http_server_bind_address" "$artifacts_root" "$(build_cancel_request_file)" "$(build_cancel_token_file)" "$results_file" >/dev/null 2>&1 &
artifact_http_server_pid=$!
sleep 0.2
if kill -0 "$artifact_http_server_pid" >/dev/null 2>&1; then
url_host=$(detect_artifact_http_server_host)
public_base_url="${artifact_public_base_url:-$(default_artifact_public_base_url "$port")}"
artifact_http_server_local_url="http://$url_host:$port/"
artifact_http_server_url=$(normalize_base_url "$public_base_url")
echo "Artifact HTTP server: $artifact_http_server_local_url"
echo "Artifact public URL: $artifact_http_server_url"
echo "Artifact HTTP server bind address: $artifact_http_server_bind_address"
echo "Artifact directory: file://$artifacts_root"
return 0
fi
wait "$artifact_http_server_pid" 2>/dev/null || true
artifact_http_server_pid=
port=$((port + 1))
done
echo "WARNING: could not start artifact HTTP server on ${artifact_http_server_bind_address}:${artifact_http_server_port}-${max_port}." >&2
}
open_artifact_frontend_if_requested() {
if [ "$open_artifact_frontend" != true ]; then
return 0
fi
if [ -z "$artifact_http_server_local_url" ]; then
echo "WARNING: cannot open artifact frontend because the HTTP server is not running." >&2
return 0
fi
open_in_local_browser "artifact frontend" "$artifact_http_server_local_url"
}
artifact_nginx_write_ssl_options() {
local certbot_conf="$1"
mkdir -p "$certbot_conf" || return 1
cat >"$certbot_conf/options-ssl-nginx.conf" <<'EOF'
ssl_session_cache shared:le_nginx_SSL:1m;
ssl_session_timeout 1440m;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_prefer_server_ciphers off;
ssl_ciphers "ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384";
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
EOF
if [ ! -f "$certbot_conf/ssl-dhparams.pem" ]; then
if command -v curl >/dev/null 2>&1; then
curl -fsSL -o "$certbot_conf/ssl-dhparams.pem" \
https://raw.githubusercontent.com/certbot/certbot/master/certbot/certbot/ssl-dhparams.pem || return 1
else
echo "WARNING: cannot prepare nginx SSL parameters because curl is not in PATH." >&2
return 1
fi
fi
chmod 644 "$certbot_conf/options-ssl-nginx.conf" "$certbot_conf/ssl-dhparams.pem"
}
artifact_nginx_write_config() {
local config_file="$1"
local mode="$2"
if [ "$mode" = init ]; then
cat >"$config_file" <<EOF
events {}
http {
server {
listen 80;
server_name $artifact_nginx_server_name;
location /.well-known/acme-challenge/ {
root /var/www/certbot;
}
location / {
return 301 https://\$host\$request_uri;
}
}
}
EOF
return 0
fi
{
cat <<EOF
events {}
http {
server {
listen 80;
server_name $artifact_nginx_server_name;
location /.well-known/acme-challenge/ {
root /var/www/certbot;
}
location / {
return 301 https://\$host\$request_uri;
}
}
server {
listen 443 ssl;
server_name $artifact_nginx_server_name;
ssl_certificate /etc/letsencrypt/live/$artifact_nginx_server_name/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/$artifact_nginx_server_name/privkey.pem;
include /etc/letsencrypt/options-ssl-nginx.conf;
location /.well-known/acme-challenge/ {
root /var/www/certbot;
}
EOF
cat <<EOF
location / {
EOF
cat <<EOF
proxy_pass http://$artifact_nginx_upstream_host:$artifact_http_server_port;
proxy_set_header Host \$host;
proxy_set_header X-Real-IP \$remote_addr;
proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto \$scheme;
add_header Cache-Control "no-store, max-age=0" always;
add_header Pragma "no-cache" always;
add_header Expires "0" always;
}
}
}
EOF
} >"$config_file"
}
remove_docker_container_by_name() {
local container_name="$1"
if [ -n "$container_name" ]; then
docker rm -f "$container_name" >/dev/null 2>&1 || true
fi
}
remove_gittally_containers_by_label() {
local role="$1"
local container_ids
container_ids=$(docker ps -aq \
--filter "label=org.hostsharing.gittally=true" \
--filter "label=org.hostsharing.gittally.repository=$(repository_key)" \
--filter "label=org.hostsharing.gittally.role=$role" 2>/dev/null || true)
if [ -n "$container_ids" ]; then
docker rm -f $container_ids >/dev/null 2>&1 || true
fi
}
cleanup_stale_build_runtime() {
if ! command -v docker >/dev/null 2>&1; then
return 0
fi
remove_docker_container_by_name "$(docker_build_container_name)"
remove_gittally_containers_by_label build
}
container_ports_include_host_port() {
local ports="$1"
local port="$2"
[[ "$ports" == *":$port->"* ]]
}
remove_gittally_port_containers() {
local port
local container_id
local container_name
local container_ports
local container_labels
for port in "$@"; do
while IFS=$'\t' read -r container_id container_name container_ports container_labels; do
if [ -z "$container_id" ]; then
continue
fi
if ! container_ports_include_host_port "$container_ports" "$port"; then
continue
fi
if [[ "$container_labels" == *"org.hostsharing.gittally=true"* ]] ||
[[ "$container_name" == gittally-* ]] ||
[[ "$container_name" == git-watch-origin-and-test-nginx-* ]]; then
echo "Removing stale GitTally container using port $port: $container_name"
docker rm -f "$container_id" >/dev/null 2>&1 || true
fi
done < <(docker ps --format '{{.ID}}\t{{.Names}}\t{{.Ports}}\t{{.Labels}}' 2>/dev/null || true)
done
}
warn_remaining_port_owners() {
local port
local container_id
local container_name
local container_ports
local container_labels
local has_owner=false
for port in "$@"; do
while IFS=$'\t' read -r container_id container_name container_ports container_labels; do
if [ -z "$container_id" ]; then
continue
fi
if container_ports_include_host_port "$container_ports" "$port"; then
has_owner=true
echo "WARNING: artifact nginx port $port is already used by Docker container $container_name ($container_id)." >&2
fi
done < <(docker ps --format '{{.ID}}\t{{.Names}}\t{{.Ports}}\t{{.Labels}}' 2>/dev/null || true)
done
if [ "$has_owner" = true ]; then
echo "WARNING: remaining containers still use artifact nginx ports." >&2
return 1
fi
return 0
}
artifact_nginx_ports_free() {
local port
local container_id
local container_name
local container_ports
local container_labels
for port in "$@"; do
while IFS=$'\t' read -r container_id container_name container_ports container_labels; do
if [ -n "$container_id" ] && container_ports_include_host_port "$container_ports" "$port"; then
return 1
fi
done < <(docker ps --format '{{.ID}}\t{{.Names}}\t{{.Ports}}\t{{.Labels}}' 2>/dev/null || true)
done
return 0
}
wait_for_artifact_nginx_ports() {
local attempt
if ! warn_remaining_port_owners "$artifact_nginx_http_port" "$artifact_nginx_https_port"; then
sleep 1
fi
for attempt in 1 2 3 4; do
if artifact_nginx_ports_free "$artifact_nginx_http_port" "$artifact_nginx_https_port"; then
return 0
fi
sleep 1
done
artifact_nginx_ports_free "$artifact_nginx_http_port" "$artifact_nginx_https_port"
}
cleanup_stale_artifact_nginx_containers() {
remove_docker_container_by_name "$artifact_nginx_container_name"
remove_gittally_containers_by_label nginx
remove_gittally_port_containers "$artifact_nginx_http_port" "$artifact_nginx_https_port"
wait_for_artifact_nginx_ports
}
artifact_nginx_run_container() {
local config_file="$1"
local certbot_conf="$2"
local certbot_www="$3"
local nginx_log="$4"
local container_id
local -a docker_args
local -a docker_label_args
remove_docker_container_by_name "$artifact_nginx_container_name"
artifact_nginx_container_started=false
artifact_nginx_container_id=
docker_args=(run -d --name "$artifact_nginx_container_name" \
--publish "$artifact_nginx_http_port:80" \
--publish "$artifact_nginx_https_port:443" \
--network bridge \
-v "$certbot_conf:/etc/letsencrypt" \
-v "$certbot_www:/var/www/certbot" \
-v "$nginx_log:/var/log/nginx" \
-v "$config_file:/etc/nginx/nginx.conf:ro")
mapfile -t docker_label_args < <(gittally_docker_label_args nginx)
docker_args+=("${docker_label_args[@]}")
container_id=$(docker "${docker_args[@]}" nginx) || return 1
artifact_nginx_container_id="$container_id"
artifact_nginx_container_started=true
}
artifact_nginx_obtain_or_renew_certificate() {
local certbot_conf="$1"
local certbot_www="$2"
local certbot_log="$3"
local cert_file="$certbot_conf/live/$artifact_nginx_server_name/fullchain.pem"
local -a email_args=()
if [ -n "$artifact_letsencrypt_email" ]; then
email_args=(--email "$artifact_letsencrypt_email")
else
email_args=(--register-unsafely-without-email)
fi
if [ -f "$cert_file" ]; then
docker run --rm \
-v "$certbot_conf:/etc/letsencrypt" \
-v "$certbot_www:/var/www/certbot" \
-v "$certbot_log:/var/log/letsencrypt" \
certbot/certbot renew -q
return $?
fi
docker run --rm \
-v "$certbot_conf:/etc/letsencrypt" \
-v "$certbot_www:/var/www/certbot" \
-v "$certbot_log:/var/log/letsencrypt" \
certbot/certbot \
certonly --webroot --webroot-path /var/www/certbot --cert-name "$artifact_nginx_server_name" \
-d "$artifact_nginx_server_name" --rsa-key-size 4096 \
--non-interactive --agree-tos "${email_args[@]}"
}
start_artifact_nginx() {
local certbot_conf
local certbot_www
local certbot_log
local nginx_log
local config_file
local cert_file
if [ "$use_artifact_nginx" != true ]; then
return 0
fi
if ! command -v docker >/dev/null 2>&1; then
echo "WARNING: cannot start artifact nginx because docker is not in PATH." >&2
return 0
fi
if [ -z "$artifact_nginx_server_name" ]; then
echo "WARNING: cannot start artifact nginx because GITTALLY_ARTIFACT_NGINX_SERVER_NAME is empty." >&2
return 0
fi
if ! [[ "$artifact_nginx_http_port" =~ ^[0-9]+$ ]] ||
! [[ "$artifact_nginx_https_port" =~ ^[0-9]+$ ]] ||
[ "$artifact_nginx_http_port" -lt 1 ] || [ "$artifact_nginx_http_port" -gt 65535 ] ||
[ "$artifact_nginx_https_port" -lt 1 ] || [ "$artifact_nginx_https_port" -gt 65535 ]; then
echo "WARNING: cannot start artifact nginx because nginx ports are invalid." >&2
return 0
fi
if [ -z "$artifact_http_server_url" ]; then
echo "WARNING: cannot start artifact nginx because artifact HTTP server is not running." >&2
return 0
fi
if ! cleanup_stale_artifact_nginx_containers; then
echo "WARNING: artifact nginx was not started because a configured port is still in use." >&2
return 0
fi
certbot_conf="$artifact_nginx_state_dir/certbot/conf"
certbot_www="$artifact_nginx_state_dir/certbot/www"
certbot_log="$artifact_nginx_state_dir/certbot/log"
nginx_log="$artifact_nginx_state_dir/nginx/log"
config_file="$artifact_nginx_state_dir/nginx/nginx.conf"
cert_file="$certbot_conf/live/$artifact_nginx_server_name/fullchain.pem"
mkdir -p "$certbot_www" "$certbot_log" "$nginx_log" "$(dirname "$config_file")" || {
echo "WARNING: could not prepare artifact nginx state directory: $artifact_nginx_state_dir" >&2
return 0
}
chmod 755 "$certbot_www" "$certbot_log" "$nginx_log"
artifact_nginx_write_ssl_options "$certbot_conf" || {
echo "WARNING: could not prepare artifact nginx SSL options." >&2
return 0
}
if [ -f "$cert_file" ]; then
artifact_nginx_write_config "$config_file" full || return 0
else
artifact_nginx_write_config "$config_file" init || return 0
fi
if ! artifact_nginx_run_container "$config_file" "$certbot_conf" "$certbot_www" "$nginx_log"; then
echo "WARNING: could not start artifact nginx container $artifact_nginx_container_name." >&2
return 0
fi
if ! artifact_nginx_obtain_or_renew_certificate "$certbot_conf" "$certbot_www" "$certbot_log"; then
echo "WARNING: artifact nginx is running, but Let's Encrypt certificate setup failed." >&2
return 0
fi
artifact_nginx_write_config "$config_file" full || return 0
if ! artifact_nginx_run_container "$config_file" "$certbot_conf" "$certbot_www" "$nginx_log"; then
echo "WARNING: certificate is available, but restarting artifact nginx with HTTPS failed." >&2
return 0
fi
echo "Artifact nginx proxy: http://$artifact_nginx_server_name:$artifact_nginx_http_port/ -> https://$artifact_nginx_server_name:$artifact_nginx_https_port/"
echo "Artifact nginx public URL: $(normalize_base_url "${artifact_public_base_url:-https://$artifact_nginx_server_name/}")"
echo "Artifact nginx upstream: http://$artifact_nginx_upstream_host:$artifact_http_server_port/"
echo "Artifact nginx state: $artifact_nginx_state_dir"
}
is_below_known_report_index() {
local report_dir="$1"
shift
local known_report_dir
for known_report_dir in "$@"; do
if [ "$known_report_dir" = "." ]; then
return 0
fi
if [[ "$report_dir" == "$known_report_dir"/* ]]; then
return 0
fi
done
return 1
}
archived_artefact_dir_path() {
local report_dir="$1"
if [ "$report_dir" = build/reports ]; then
echo reports
else
echo "reports/$report_dir"
fi
}
write_archived_artefact_dir_links() {
local index_file="$1"
local artifact_tmp_dir="$2"
local report_dir
local archived_path
local has_artefact_dirs=false
local IFS=';'
for report_dir in $build_artefact_dirs; do
if [ -z "$report_dir" ]; then
continue
fi
archived_path=$(archived_artefact_dir_path "$report_dir")
if [ ! -d "$artifact_tmp_dir/$archived_path" ]; then
continue
fi
has_artefact_dirs=true
write_html_link "$index_file" "$archived_path/" "$report_dir"
done
[ "$has_artefact_dirs" = true ]
}
write_artifact_index() {
local branch="$1"
local artifact_tmp_dir="$2"
local effective_build_command="$3"
local index_file
local relative_index
local report_dir
local -a report_index_dirs=()
index_file=$(build_artifact_index_content_file "$artifact_tmp_dir")
{
printf ' <article>\n'
printf ' <h2>Logs</h2>\n'
printf ' <ul>\n'
} >"$index_file"
printf ' <li>Build command:<br/><code>%s</code></li>\n' \
"$(printf '%s' "$effective_build_command" | html_escape)" \
>>"$index_file"
if [ -f "$artifact_tmp_dir/$build_stdout_log" ]; then
write_html_link "$index_file" "$build_stdout_log" "Build stdout"
elif [ -f "$artifact_tmp_dir/gradle.stdout.log" ]; then
write_html_link "$index_file" "gradle.stdout.log" "Build stdout"
fi
if [ -f "$artifact_tmp_dir/$build_stderr_log" ]; then
write_html_link "$index_file" "$build_stderr_log" "Build stderr"
elif [ -f "$artifact_tmp_dir/gradle.stderr.log" ]; then
write_html_link "$index_file" "gradle.stderr.log" "Build stderr"
fi
{
printf ' </ul>\n'
printf ' <h2>Build Artifacts</h2>\n'
printf ' <ul>\n'
} >>"$index_file"
if [ -d "$artifact_tmp_dir/reports" ]; then
write_archived_artefact_dir_links "$index_file" "$artifact_tmp_dir" || \
write_html_link "$index_file" "reports/" "Archived artifact directories"
while IFS= read -r relative_index; do
report_dir=$(dirname "$relative_index")
if is_below_known_report_index "$report_dir" "${report_index_dirs[@]}"; then
continue
fi
report_index_dirs+=("$report_dir")
write_html_link "$index_file" "reports/$relative_index" "reports/$relative_index"
done < <(find "$artifact_tmp_dir/reports" -type f -name index.html -printf '%P\n' | awk '{ path=$0; depth=gsub("/", "/", path); print depth, length($0), $0 }' | sort -n -s | cut -d' ' -f3-)
else
printf ' <li>No artifact directories were produced by this build.</li>\n' >>"$index_file"
fi
{
printf ' </ul>\n'
printf ' </article>\n'
} >>"$index_file"
}
copy_build_artefact_dirs() {
local artifact_tmp_dir="$1"
local report_dir
local report_target
local IFS=';'
for report_dir in $build_artefact_dirs; do
if [ -z "$report_dir" ] || [ ! -d "$report_dir" ]; then
continue
fi
mkdir -p "$artifact_tmp_dir/reports" || return 1
if [ "$report_dir" = build/reports ]; then
cp -a "$report_dir/." "$artifact_tmp_dir/reports/" || return 1
else
report_target="$artifact_tmp_dir/$(archived_artefact_dir_path "$report_dir")"
mkdir -p "$(dirname "$report_target")" || return 1
cp -a "$report_dir" "$report_target" || return 1
fi
done
}
persist_build_artifacts() {
local branch="$1"
local stdout_file="$2"
local stderr_file="$3"
local artifact_key="$4"
local effective_build_command="$5"
local artifact_dir
local artifact_tmp_dir
artifact_dir=$(build_artifact_dir "$branch" "$artifact_key")
artifact_tmp_dir="$artifact_dir.tmp.$$"
rm -rf -- "$artifact_tmp_dir" || return 1
mkdir -p "$artifact_tmp_dir" || return 1
cp "$stdout_file" "$artifact_tmp_dir/$build_stdout_log" || {
rm -rf -- "$artifact_tmp_dir"
return 1
}
cp "$stderr_file" "$artifact_tmp_dir/$build_stderr_log" || {
rm -rf -- "$artifact_tmp_dir"
return 1
}
copy_build_artefact_dirs "$artifact_tmp_dir" || {
rm -rf -- "$artifact_tmp_dir"
return 1
}
write_html_favicon "$(build_artifacts_root)" || {
rm -rf -- "$artifact_tmp_dir"
return 1
}
write_script_download "$(build_artifacts_root)" || {
rm -rf -- "$artifact_tmp_dir"
return 1
}
write_html_about_page "$(build_artifacts_root)" || {
rm -rf -- "$artifact_tmp_dir"
return 1
}
write_html_license_page "$(build_artifacts_root)" || {
rm -rf -- "$artifact_tmp_dir"
return 1
}
write_artifact_index "$branch" "$artifact_tmp_dir" "$effective_build_command" || {
rm -rf -- "$artifact_tmp_dir"
return 1
}
rm -rf -- "$artifact_dir" || {
rm -rf -- "$artifact_tmp_dir"
return 1
}
mv "$artifact_tmp_dir" "$artifact_dir" || {
rm -rf -- "$artifact_tmp_dir"
return 1
}
echo "persisted build artifacts:"
echo "file://$artifact_dir"
if [ -n "$artifact_http_server_url" ]; then
echo "${artifact_http_server_url}branches/$artifact_key/index.html"
fi
}
prune_build_artifacts() {
local results_file
local artifacts_root
local branches_dir
local keep_file
local branch
local commit
local status
local timestamp
local duration
local artifact_key
local artifact_dir
artifacts_root=$(build_artifacts_root)
branches_dir="$artifacts_root/branches"
if [ ! -d "$branches_dir" ]; then
return 0
fi
results_file=$(build_results_file)
keep_file=$(mktemp "$artifacts_root/keep.XXXXXX")
if [ -f "$results_file" ]; then
while IFS=$'\t' read -r branch commit status timestamp duration artifact_key; do
normalize_build_result_fields
echo "$artifact_key" >>"$keep_file"
done <"$results_file"
fi
for artifact_dir in "$branches_dir"/*; do
if [ ! -d "$artifact_dir" ]; then
continue
fi
artifact_key=$(basename "$artifact_dir")
if ! grep -Fxq -- "$artifact_key" "$keep_file"; then
rm -rf -- "$artifact_dir"
fi
done
rm -f "$keep_file"
write_artifacts_root_index
}
record_build_result() {
local branch="$1"
local status="$2"
local duration="${3:-}"
local timestamp="${4:-}"
local artifact_key="${5:-}"
local results_file
local results_dir
local tmp_file
local commit
results_file=$(build_results_file)
results_dir=$(dirname "$results_file")
mkdir -p "$results_dir"
tmp_file=$(mktemp "$results_dir/build-results.XXXXXX")
commit=$(git rev-parse HEAD)
if [ -z "$timestamp" ]; then
timestamp=$(date -Iseconds)
fi
if [ -z "$artifact_key" ]; then
artifact_key=$(build_artifact_branch_key "$branch")
fi
if [ -f "$results_file" ]; then
awk -F '\t' \
-v artifact_key="$artifact_key" \
-v branch="$branch" \
'($6 == "" && artifact_key == $1) || $6 == artifact_key || ($1 == branch && ($3 == "pending" || $3 == "running")) { next } { print }' \
"$results_file" >"$tmp_file"
fi
printf '%s\t%s\t%s\t%s\t%s\t%s\n' "$branch" "$commit" "$status" "$timestamp" "$duration" "$artifact_key" >>"$tmp_file"
mv "$tmp_file" "$results_file"
publish_gitea_build_status "$commit" "$status" "$branch" "$artifact_key" || true
prune_build_results
}
prune_build_results() {
local results_file
local results_dir
local tmp_file
local filtered_file
local sorted_file
local branch
local commit
local status
local timestamp
local duration
local artifact_key
local previous_branch=
local cutoff_epoch
local timestamp_epoch
results_file=$(build_results_file)
if [ ! -f "$results_file" ]; then
prune_build_artifacts
write_artifacts_root_index
return 0
fi
results_dir=$(dirname "$results_file")
tmp_file=$(mktemp "$results_dir/build-results.XXXXXX")
filtered_file=$(mktemp "$results_dir/build-results.XXXXXX")
sorted_file=$(mktemp "$results_dir/build-results.XXXXXX")
while IFS=$'\t' read -r branch commit status timestamp duration artifact_key; do
if git show-ref --quiet --verify "refs/remotes/origin/$branch"; then
normalize_build_result_fields
printf '%s\t%s\t%s\t%s\t%s\t%s\n' "$branch" "$commit" "$status" "$timestamp" "$duration" "$artifact_key" >>"$filtered_file"
fi
done <"$results_file"
sort -t $'\t' -k1,1 -k4,4r "$filtered_file" >"$sorted_file"
if artifact_build_retention_is_count; then
awk -F '\t' -v limit="$artifact_build_retention_per_branch" '++seen[$1] <= limit' "$sorted_file" >"$tmp_file"
else
cutoff_epoch=$(artifact_build_retention_cutoff_epoch)
while IFS=$'\t' read -r branch commit status timestamp duration artifact_key; do
normalize_build_result_fields
if [ "$branch" != "$previous_branch" ]; then
printf '%s\t%s\t%s\t%s\t%s\t%s\n' "$branch" "$commit" "$status" "$timestamp" "$duration" "$artifact_key" >>"$tmp_file"
previous_branch="$branch"
continue
fi
timestamp_epoch=$(date -d "$timestamp" +%s 2>/dev/null || echo 0)
if [ "$timestamp_epoch" -ge "$cutoff_epoch" ]; then
printf '%s\t%s\t%s\t%s\t%s\t%s\n' "$branch" "$commit" "$status" "$timestamp" "$duration" "$artifact_key" >>"$tmp_file"
fi
done <"$sorted_file"
fi
mv "$tmp_file" "$results_file"
rm -f "$filtered_file" "$sorted_file"
prune_build_artifacts
write_artifacts_root_index
}
mark_running_builds_interrupted() {
local results_file
local results_dir
local tmp_file
local branch
local commit
local status
local timestamp
local duration
local artifact_key
local interrupted_count=0
local superseded_pending_count=0
local -A latest_timestamp_by_branch=()
results_file=$(build_results_file)
[ -f "$results_file" ] || return 0
results_dir=$(dirname "$results_file")
tmp_file=$(mktemp "$results_dir/build-results.XXXXXX") || return 1
while IFS=$'\t' read -r branch commit status timestamp duration artifact_key; do
normalize_build_result_fields
if [ -z "${latest_timestamp_by_branch[$branch]:-}" ] ||
[[ "$timestamp" > "${latest_timestamp_by_branch[$branch]}" ]]; then
latest_timestamp_by_branch[$branch]="$timestamp"
fi
done <"$results_file"
while IFS=$'\t' read -r branch commit status timestamp duration artifact_key; do
normalize_build_result_fields
if [ "$status" = running ]; then
status=interrupted
interrupted_count=$((interrupted_count + 1))
publish_gitea_build_status "$commit" "$status" "$branch" "$artifact_key" || true
elif [ "$status" = pending ] && [[ "$timestamp" < "${latest_timestamp_by_branch[$branch]}" ]]; then
status=interrupted
superseded_pending_count=$((superseded_pending_count + 1))
publish_gitea_build_status "$commit" "$status" "$branch" "$artifact_key" || true
fi
printf '%s\t%s\t%s\t%s\t%s\t%s\n' "$branch" "$commit" "$status" "$timestamp" "$duration" "$artifact_key" >>"$tmp_file"
done <"$results_file"
if [ "$interrupted_count" -eq 0 ] && [ "$superseded_pending_count" -eq 0 ]; then
rm -f "$tmp_file"
return 0
fi
mv "$tmp_file" "$results_file"
if [ "$interrupted_count" -gt 0 ]; then
echo "Marked $interrupted_count stale running build(s) as interrupted."
fi
if [ "$superseded_pending_count" -gt 0 ]; then
echo "Marked $superseded_pending_count superseded pending build(s) as interrupted."
fi
write_current_build_page
write_artifacts_root_index
}
is_restartable_build_status() {
case "$1" in
running|interrupted|pending)
return 0
;;
*)
return 1
;;
esac
}
latest_build_status_for_branch() {
local wanted_branch="$1"
local results_file
local branch
local commit
local status
local timestamp
local duration
local artifact_key
results_file=$(build_results_file)
[ -f "$results_file" ] || return 1
while IFS=$'\t' read -r branch commit status timestamp duration artifact_key; do
normalize_build_result_fields
if [ "$branch" = "$wanted_branch" ]; then
effective_build_status "$commit" "$status"
return 0
fi
done < <(sort -t $'\t' -k4,4r "$results_file")
return 1
}
branch_has_restartable_build() {
local branch="$1"
local status
status=$(latest_build_status_for_branch "$branch") || return 1
is_restartable_build_status "$status"
}
branch_has_failed_build() {
local branch="$1"
local status
status=$(latest_build_status_for_branch "$branch") || return 1
[ "$status" = failed ]
}
restartable_build_branches() {
local results_file
local branch
local commit
local status
local local_status
local effective_status
local timestamp
local duration
local artifact_key
local -A seen=()
results_file=$(build_results_file)
if [ ! -f "$results_file" ]; then
echo "No build results file found; no restartable builds to scan." >&2
return 0
fi
echo "Scanning latest build results for restartable builds ..." >&2
while IFS=$'\t' read -r branch commit status timestamp duration artifact_key; do
normalize_build_result_fields
if [ -n "${seen[$branch]:-}" ]; then
continue
fi
seen[$branch]=true
local_status="$status"
effective_status=$(effective_build_status "$commit" "$status")
if ! branch_exists_on_origin "$branch"; then
if is_restartable_build_status "$local_status" || is_restartable_build_status "$effective_status"; then
echo "Skipping restartable build for $branch: branch no longer exists on origin." >&2
fi
continue
fi
if is_restartable_build_status "$effective_status"; then
echo "Found restartable build: $branch ($effective_status)." >&2
echo "$branch"
elif is_restartable_build_status "$local_status"; then
echo "Skipping locally $local_status build for $branch: effective status is $effective_status." >&2
fi
done < <(sort -t $'\t' -k4,4r "$results_file")
}
failed_build_branches() {
local results_file
local branch
local commit
local status
local timestamp
local duration
local artifact_key
local -A seen=()
results_file=$(build_results_file)
[ -f "$results_file" ] || return 0
while IFS=$'\t' read -r branch commit status timestamp duration artifact_key; do
normalize_build_result_fields
if [ -n "${seen[$branch]:-}" ]; then
continue
fi
seen[$branch]=true
status=$(effective_build_status "$commit" "$status")
if [ "$status" = failed ] && branch_exists_on_origin "$branch"; then
echo "$branch"
fi
done < <(sort -t $'\t' -k4,4r "$results_file")
}
next_pending_build_branch() {
local results_file
local branch
local commit
local status
local timestamp
local duration
local artifact_key
local -A seen=()
results_file=$(build_results_file)
[ -f "$results_file" ] || return 1
while IFS=$'\t' read -r branch commit status timestamp duration artifact_key; do
normalize_build_result_fields
if [ -n "${seen[$branch]:-}" ]; then
continue
fi
seen[$branch]=true
status=$(effective_build_status "$commit" "$status")
if [ "$status" = pending ] && branch_exists_on_origin "$branch"; then
if ! branch_matches_current_worktree_branch "$branch"; then
continue
fi
echo "Picking up pending build: $branch" >&2
echo "$branch"
return 0
fi
done < <(sort -t $'\t' -k4,4r "$results_file")
return 1
}
fetch_origin() {
git_with_gitea_token fetch --prune origin >/dev/null || return 1
prune_build_results
}
retry_fetch_origin() {
until fetch_origin; do
echo "checking origin failed; retrying in 10s ..." >&2
sleep 10
done
}
print_build_results() {
local results_file
local branch
local commit
local status
local display_status
local timestamp
local duration
local artifact_key
local has_results=false
local green=""
local yellow=""
local red=""
local blue=""
local reset=""
prune_build_results
if [ -t 1 ] && command -v tput >/dev/null 2>&1; then
green=$(tput setaf 2)
yellow=$(tput setaf 3)
red=$(tput setaf 1)
blue=$(tput setaf 4)
reset=$(tput sgr0)
fi
results_file=$(build_results_file)
echo
print_build_banner "latest build results:"
if [ -f "$results_file" ]; then
while IFS=$'\t' read -r branch commit status timestamp duration artifact_key; do
normalize_build_result_fields
display_status=$(effective_build_status "$commit" "$status")
case "$display_status" in
success|passed)
echo "${green}success: $branch${reset}"
has_results=true
;;
failed)
echo "${red}failed: $branch${reset}"
has_results=true
;;
interrupted)
echo "${yellow}interrupted: $branch${reset}"
has_results=true
;;
cancelled)
echo "${yellow}cancelled: $branch${reset}"
has_results=true
;;
running)
echo "${blue}running: $branch${reset}"
has_results=true
;;
pending)
echo "${yellow}pending: $branch${reset}"
has_results=true
;;
esac
done <"$results_file"
fi
if [ "$has_results" = false ]; then
echo "(none)"
fi
printf '%*s\n' 80 '' | tr ' ' '-'
echo
}
print_build_summary() {
local branch="$1"
local status="$2"
echo "BUILD $status: $branch"
}
branch_config_value_or_bootstrap() {
local primary_name="$1"
local fallback_name="$2"
local bootstrap_value="$3"
local branch_value
if branch_value=$(branch_config_value "$primary_name" "$fallback_name"); then
printf '%s' "$branch_value"
else
printf '%s' "$bootstrap_value"
fi
}
resolve_branch_docker_config() {
docker_build_image=$(branch_config_value_or_bootstrap \
GITTALLY_BUILD_DOCKER_IMAGE HSADMIN_NG_BUILD_IMAGE "$bootstrap_docker_build_image")
docker_build_dockerfile=$(branch_config_value_or_bootstrap \
GITTALLY_BUILD_DOCKERFILE "" "$bootstrap_docker_build_dockerfile")
docker_build_context=$(branch_config_value_or_bootstrap \
GITTALLY_BUILD_DOCKER_CONTEXT "" "$bootstrap_docker_build_context")
docker_build_network=$(branch_config_value_or_bootstrap \
GITTALLY_BUILD_DOCKER_NETWORK HSADMIN_NG_BUILD_NETWORK "$bootstrap_docker_build_network")
docker_build_preflight_command=$(branch_config_value_or_bootstrap \
GITTALLY_BUILD_DOCKER_PREFLIGHT_COMMAND "" "$bootstrap_docker_build_preflight_command")
docker_build_env=$(branch_config_value_or_bootstrap \
GITTALLY_BUILD_DOCKER_ENV "" "$bootstrap_docker_build_env")
docker_build_java_tool_options=$(branch_config_value_or_bootstrap \
GITTALLY_BUILD_DOCKER_JAVA_TOOL_OPTIONS "" "$bootstrap_docker_build_java_tool_options")
}
file_sha256() {
local file="$1"
if [ ! -f "$file" ]; then
echo "ERROR: Dockerfile not found: $file" >&2
return 1
fi
if command -v sha256sum >/dev/null 2>&1; then
sha256sum "$file" | awk '{ print $1 }'
elif command -v shasum >/dev/null 2>&1; then
shasum -a 256 "$file" | awk '{ print $1 }'
else
echo "ERROR: cannot calculate Dockerfile checksum; sha256sum or shasum is required." >&2
return 1
fi
}
text_sha256() {
if command -v sha256sum >/dev/null 2>&1; then
sha256sum | awk '{ print $1 }'
elif command -v shasum >/dev/null 2>&1; then
shasum -a 256 | awk '{ print $1 }'
else
echo "ERROR: cannot calculate checksum; sha256sum or shasum is required." >&2
return 1
fi
}
docker_build_inputs_sha256() {
local dockerfile_hash
dockerfile_hash=$(file_sha256 "$docker_build_dockerfile") || return 1
printf '%s\n%s\n%s\n' "$dockerfile_hash" "$docker_build_dockerfile" "$docker_build_context" | text_sha256
}
ensure_docker_build_image() {
local dockerfile_hash
local build_inputs_hash
local image_build_inputs_hash
if ! command -v docker >/dev/null 2>&1; then
echo "ERROR: --docker requires docker in PATH." >&2
return 1
fi
dockerfile_hash=$(file_sha256 "$docker_build_dockerfile") || return 1
build_inputs_hash=$(docker_build_inputs_sha256) || return 1
if docker image inspect "$docker_build_image" >/dev/null 2>&1; then
image_build_inputs_hash=$(docker image inspect "$docker_build_image" \
--format '{{ index .Config.Labels "org.gittally.build-inputs-sha256" }}' 2>/dev/null || true)
if [ "$image_build_inputs_hash" = "$build_inputs_hash" ]; then
return 0
fi
echo "Docker build image is stale: $docker_build_image"
echo "Docker build input checksum changed or is missing on the image label."
else
echo "Docker build image not found: $docker_build_image"
fi
echo "Building Docker image from $docker_build_dockerfile ..."
docker build \
--label "org.gittally.dockerfile=$docker_build_dockerfile" \
--label "org.gittally.dockerfile-sha256=$dockerfile_hash" \
--label "org.gittally.build-context=$docker_build_context" \
--label "org.gittally.build-inputs-sha256=$build_inputs_hash" \
-t "$docker_build_image" \
-f "$docker_build_dockerfile" \
"$docker_build_context" || return 1
}
run_build_command() {
local branch="$1"
local effective_build_command="$2"
if [ "$use_docker_build" = true ]; then
run_build_command_in_docker "$branch" "$effective_build_command"
else
if [ -n "$build_clean_command" ]; then
branch="$branch" bash -c "$build_clean_command" || return 1
fi
branch="$branch" bash -c "$effective_build_command"
fi
}
docker_gradle_user_home_volume_name() {
echo "gittally-gradle-$(safe_container_name_part "$(repository_key)")"
}
docker_build_container_name() {
echo "gittally-build-$(safe_container_name_part "$(repository_key)")"
}
prepare_docker_gradle_volume() {
local gradle_user_home_volume="$1"
local uid
local gid
uid=$(id -u)
gid=$(id -g)
echo "Preparing Docker Gradle cache volume: $gradle_user_home_volume ..."
docker volume create "$gradle_user_home_volume" >/dev/null || {
echo "ERROR: cannot create Docker Gradle cache volume: $gradle_user_home_volume" >&2
return 1
}
docker run --rm --user 0 \
--volume "$gradle_user_home_volume:/gradle-user-home" \
"$docker_build_image" \
sh -c 'mkdir -p /gradle-user-home/wrapper/dists && chown -R "$1:$2" /gradle-user-home && chmod -R u+rwX /gradle-user-home' \
sh "$uid" "$gid" || {
echo "ERROR: cannot prepare Docker Gradle cache volume: $gradle_user_home_volume" >&2
return 1
}
}
prepare_docker_workspace_build_dir() {
local uid
local gid
uid=$(id -u)
gid=$(id -g)
echo "Preparing Docker workspace build directory ..."
docker run --rm --user 0 \
--workdir "$PWD" \
--volume "$PWD:$PWD" \
"$docker_build_image" \
sh -c '
bash -c "$3" &&
mkdir -p build &&
chown -R "$1:$2" build &&
chmod -R u+rwX build
' \
sh "$uid" "$gid" "$build_clean_command" || {
echo "ERROR: cannot prepare Docker workspace build directory: $PWD/build" >&2
return 1
}
}
repair_docker_workspace_ownership() {
local uid
local gid
uid=$(id -u)
gid=$(id -g)
docker run --rm --user 0 \
--workdir "$PWD" \
--volume "$PWD:$PWD" \
"$docker_build_image" \
sh -c '
for path in build .gradle; do
if [ -e "$path" ]; then
chown -R "$1:$2" "$path" &&
chmod -R u+rwX "$path"
fi
done
' \
sh "$uid" "$gid" || {
echo "WARNING: could not repair Docker workspace ownership." >&2
return 1
}
}
run_build_command_in_docker() {
local branch="$1"
local effective_build_command="$2"
local docker_host="${DOCKER_HOST:-}"
local container_docker_host="$docker_host"
local docker_container_user
local docker_socket_path=
local docker_socket_group=
local build_exit_code
local gradle_user_home_volume
local build_container_name
local env_name
local build_env_assignment
local testcontainers_host_override="${TESTCONTAINERS_HOST_OVERRIDE:-}"
local java_tool_options="${JAVA_TOOL_OPTIONS:-}"
local -a docker_args
local -a docker_label_args
local -a build_env_assignments=()
if [ -z "$docker_host" ]; then
if [ -S "/run/user/$(id -u)/docker.sock" ]; then
docker_host="unix:///run/user/$(id -u)/docker.sock"
else
docker_host="unix:///var/run/docker.sock"
fi
container_docker_host="$docker_host"
fi
ensure_docker_build_image || return 1
gradle_user_home_volume=$(docker_gradle_user_home_volume_name)
prepare_docker_gradle_volume "$gradle_user_home_volume" || return 1
prepare_docker_workspace_build_dir || return 1
build_container_name=$(docker_build_container_name)
remove_docker_container_by_name "$build_container_name"
docker_args=(run --rm --name "$build_container_name")
mapfile -t docker_label_args < <(gittally_docker_label_args build)
docker_args+=("${docker_label_args[@]}")
if [ -t 0 ]; then
docker_args+=(--interactive)
fi
if [ -t 1 ]; then
docker_args+=(--tty)
fi
docker_container_user=0
docker_args+=(
--workdir "$PWD"
--volume "$PWD:$PWD"
--volume "$gradle_user_home_volume:/gradle-user-home"
--env "HOME=/tmp/docker-home"
--env "GRADLE_USER_HOME=/gradle-user-home"
--env "branch=$branch"
)
if [ -n "$docker_build_env" ]; then
read -r -a build_env_assignments <<<"$docker_build_env"
for build_env_assignment in "${build_env_assignments[@]}"; do
docker_args+=(--env "$build_env_assignment")
done
fi
if [ -n "$docker_build_network" ]; then
docker_args+=(--network "$docker_build_network")
fi
if [[ "$docker_host" == unix://* ]]; then
docker_socket_path="${docker_host#unix://}"
if [ ! -S "$docker_socket_path" ]; then
echo "ERROR: Docker socket not found: $docker_socket_path" >&2
return 1
fi
if [ "$docker_socket_path" = "/run/user/$(id -u)/docker.sock" ]; then
docker_container_user="$(id -u)"
fi
container_docker_host="unix:///var/run/docker.sock"
docker_args+=(
--volume "$docker_socket_path:/var/run/docker.sock"
--env "TESTCONTAINERS_DOCKER_SOCKET_OVERRIDE=/var/run/docker.sock"
)
docker_socket_group=$(stat -c '%g' "$docker_socket_path" 2>/dev/null || true)
if [ -n "$docker_socket_group" ] && [ "$docker_socket_path" != "/run/user/$(id -u)/docker.sock" ]; then
docker_args+=(--group-add "$docker_socket_group")
fi
fi
docker_args+=(--user "$docker_container_user")
docker_args+=(--env "DOCKER_HOST=$container_docker_host")
if [ -z "$testcontainers_host_override" ]; then
if [ "$docker_build_network" = host ]; then
testcontainers_host_override=localhost
else
testcontainers_host_override=host.docker.internal
docker_args+=(--add-host "host.docker.internal:host-gateway")
fi
fi
docker_args+=(--env "TESTCONTAINERS_HOST_OVERRIDE=$testcontainers_host_override")
java_tool_options="$java_tool_options $docker_build_java_tool_options"
java_tool_options="$java_tool_options -Dtestcontainers.host.override=$testcontainers_host_override"
docker_args+=(--env "JAVA_TOOL_OPTIONS=$java_tool_options")
for env_name in \
HSADMINNG_POSTGRES_ADMIN_USERNAME \
HSADMINNG_POSTGRES_RESTRICTED_USERNAME \
HSADMINNG_MIGRATION_DATA_PATH \
TESTCONTAINERS_LOG_LEVEL; do
if [ -n "${!env_name+x}" ]; then
docker_args+=(--env "$env_name")
fi
done
echo "Checking Docker access inside build container ..."
if [ -n "$docker_build_preflight_command" ] &&
! docker "${docker_args[@]}" "$docker_build_image" bash -c "$docker_build_preflight_command" >/dev/null; then
echo "ERROR: Docker is not reachable from inside the build container." >&2
echo " DOCKER_HOST inside container: $container_docker_host" >&2
echo " TESTCONTAINERS_HOST_OVERRIDE inside container: $testcontainers_host_override" >&2
return 1
fi
echo "Docker image: $docker_build_image"
if [[ "$container_docker_host" == unix://* ]]; then
docker "${docker_args[@]}" "$docker_build_image" \
sh -c 'mkdir -p "$HOME" && {
printf "%s\n" "docker.client.strategy=org.testcontainers.dockerclient.UnixSocketClientProviderStrategy"
printf "%s\n" "docker.host=unix:///var/run/docker.sock"
printf "%s\n" "testcontainers.docker.socket.override=/var/run/docker.sock"
printf "%s\n" "testcontainers.host.override=$TESTCONTAINERS_HOST_OVERRIDE"
} >"$HOME/.testcontainers.properties" && exec bash -c "$1"' \
sh "$effective_build_command"
else
docker "${docker_args[@]}" "$docker_build_image" bash -c "$effective_build_command"
fi
build_exit_code=$?
repair_docker_workspace_ownership || true
return "$build_exit_code"
}
branch_config_build_command() {
local checkout_repo_root
local config_file
local value_file
local status
checkout_repo_root=$(git rev-parse --show-toplevel 2>/dev/null) || return 1
config_file="$checkout_repo_root/.gitTally"
if [ ! -f "$config_file" ]; then
return 1
fi
value_file=$(mktemp "${TMPDIR:-/tmp}/gittally-build-command.XXXXXX") || return 1
(
unset GITTALLY_BUILD_COMMAND
set -a
# shellcheck source=/dev/null
. "$config_file" >/dev/null
set +a
if [ -n "${GITTALLY_BUILD_COMMAND+x}" ]; then
printf '%s' "$GITTALLY_BUILD_COMMAND" >"$value_file"
else
exit 1
fi
)
status=$?
if [ "$status" -eq 0 ]; then
cat "$value_file"
fi
rm -f "$value_file"
return "$status"
}
build_command_for_current_checkout() {
local branch_build_command
if branch_build_command=$(branch_config_build_command); then
printf '%s' "$branch_build_command"
else
printf '%s' "$environment_build_command"
fi
}
build_current_checkout() {
local branch
local build_exit_code
local effective_build_command
local started_at
local ended_at
local started_timestamp
local ended_timestamp
local build_duration
local artifact_key
local build_lock_fd=
local build_lock_path
local build_lock_dir
local artifacts_root
local current_log_file
local build_stdout_file
local build_stderr_file
local build_stdout_pipe
local build_stderr_pipe
local tee_stdout_pid
local tee_stderr_pid
branch=$(git branch --show-current)
if [ -z "$branch" ]; then
branch="detached HEAD"
fi
effective_build_command=$(build_command_for_current_checkout) || return 1
if [ "$use_docker_build" = true ]; then
resolve_branch_docker_config
fi
print_build_banner "building branch: $branch"
echo "working directory: $PWD"
if [[ "$effective_build_command" == *"./gradlew"* ]] && [ ! -x ./gradlew ]; then
echo "ERROR: ./gradlew not found or not executable in $PWD" >&2
return 1
fi
if [[ "$effective_build_command" == *"./gradlew"* ]]; then
echo "gradle wrapper: $(realpath ./gradlew)"
fi
if [ "$use_docker_build" = true ]; then
echo "build runtime: Docker image $docker_build_image"
fi
echo "build command: $effective_build_command"
if [ "$use_docker_build" != true ] && [ -n "$build_clean_command" ]; then
echo "clean command: $build_clean_command"
fi
started_at=$(date +%s)
started_timestamp=$(date -Iseconds)
artifact_key=$(build_artifact_key "$branch" "$started_timestamp")
active_build_branch="$branch"
active_build_artifact_key="$artifact_key"
active_build_started_at="$started_at"
record_build_result "$branch" pending "" "$started_timestamp" "$artifact_key"
clear_build_cancel_request
if ! write_build_cancel_token; then
echo "WARNING: could not prepare build cancellation token." >&2
fi
artifacts_root=$(build_artifacts_root)
mkdir -p "$artifacts_root" || return 1
current_log_file=$(current_build_log_file)
{
printf 'building branch: %s\n' "$branch"
printf 'started: %s\n' "$(display_build_timestamp "$started_timestamp")"
printf 'working directory: %s\n' "$PWD"
printf 'build command: %s\n' "$effective_build_command"
if [ "$use_docker_build" != true ] && [ -n "$build_clean_command" ]; then
printf 'clean command: %s\n' "$build_clean_command"
fi
printf '\n\n'
} >"$current_log_file"
write_current_build_page "$branch" running "$started_timestamp"
build_stdout_file=$(mktemp "$artifacts_root/build-stdout.XXXXXX") || return 1
build_stderr_file=$(mktemp "$artifacts_root/build-stderr.XXXXXX") || {
rm -f "$build_stdout_file"
return 1
}
build_stdout_pipe=$(mktemp "$artifacts_root/build-stdout-pipe.XXXXXX") || {
rm -f "$build_stdout_file" "$build_stderr_file"
return 1
}
build_stderr_pipe=$(mktemp "$artifacts_root/build-stderr-pipe.XXXXXX") || {
rm -f "$build_stdout_file" "$build_stderr_file" "$build_stdout_pipe"
return 1
}
rm -f "$build_stdout_pipe" "$build_stderr_pipe"
if ! mkfifo "$build_stdout_pipe" "$build_stderr_pipe"; then
rm -f "$build_stdout_file" "$build_stderr_file" "$build_stdout_pipe" "$build_stderr_pipe"
return 1
fi
build_lock_path=$(build_lock_file)
build_lock_dir=$(dirname "$build_lock_path")
mkdir -p "$build_lock_dir"
if command -v flock >/dev/null 2>&1; then
exec {build_lock_fd}>"$build_lock_path" || {
rm -f "$build_stdout_file" "$build_stderr_file" "$build_stdout_pipe" "$build_stderr_pipe"
return 1
}
echo "waiting for build lock: $build_lock_path"
if ! flock -n "$build_lock_fd"; then
echo "build lock is already held: $build_lock_path"
cleanup_stale_build_runtime || true
terminate_stale_build_lock_holders "$build_lock_path" || true
echo "waiting up to 30s for build lock: $build_lock_path"
fi
if ! flock -w 30 "$build_lock_fd"; then
echo "ERROR: could not acquire build lock: $build_lock_path" >&2
exec {build_lock_fd}>&-
rm -f "$build_stdout_file" "$build_stderr_file" "$build_stdout_pipe" "$build_stderr_pipe"
return 1
fi
echo "acquired build lock: $build_lock_path"
fi
tee "$build_stdout_file" <"$build_stdout_pipe" | tee -a "$current_log_file" &
tee_stdout_pid=$!
tee "$build_stderr_file" <"$build_stderr_pipe" | tee -a "$current_log_file" >&2 &
tee_stderr_pid=$!
record_build_result "$branch" running "" "$started_timestamp" "$artifact_key"
run_build_command "$branch" "$effective_build_command" >"$build_stdout_pipe" 2>"$build_stderr_pipe" &
active_build_pid=$!
wait_for_active_build "$active_build_pid"
build_exit_code=$?
active_build_pid=
clear_build_cancel_request
wait "$tee_stdout_pid" || true
wait "$tee_stderr_pid" || true
rm -f "$build_stdout_pipe" "$build_stderr_pipe"
ended_at=$(date +%s)
ended_timestamp=$(date -Iseconds)
build_duration=$(format_build_duration "$((ended_at - started_at))")
if [ "$active_build_cancelled" = true ]; then
echo "build cancelled after $build_duration"
printf '\nbuild cancelled after %s\n' "$build_duration" >>"$current_log_file"
else
echo "build finished with exit code $build_exit_code after $build_duration"
printf '\nbuild finished with exit code %s after %s\n' "$build_exit_code" "$build_duration" >>"$current_log_file"
fi
persist_build_artifacts "$branch" "$build_stdout_file" "$build_stderr_file" "$artifact_key" "$effective_build_command" || \
echo "WARNING: could not persist build artifacts for branch: $branch" >&2
rm -f "$build_stdout_file" "$build_stderr_file"
if [ -n "$build_lock_fd" ]; then
flock -u "$build_lock_fd" || true
exec {build_lock_fd}>&-
fi
if [ "$active_build_cancelled" = true ]; then
active_build_branch=
active_build_artifact_key=
active_build_started_at=
active_build_cancelled=false
record_build_result "$branch" cancelled "$build_duration" "$ended_timestamp" "$artifact_key"
write_current_build_page "$branch" cancelled "$started_timestamp"
print_build_summary "$branch" CANCELLED
print_build_results
elif [ "$build_exit_code" -eq 0 ]; then
active_build_branch=
active_build_artifact_key=
active_build_started_at=
record_build_result "$branch" success "$build_duration" "$ended_timestamp" "$artifact_key"
write_current_build_page "$branch" success "$started_timestamp"
print_build_summary "$branch" SUCCESS
print_build_results
else
active_build_branch=
active_build_artifact_key=
active_build_started_at=
record_build_result "$branch" failed "$build_duration" "$ended_timestamp" "$artifact_key"
write_current_build_page "$branch" failed "$started_timestamp"
print_build_summary "$branch" FAILED
handle_build_failure_prompt "$branch" "$artifact_key"
print_build_results
return 0
fi
}
has_new_commits() {
local local_ref="$1"
local upstream_ref="$2"
local count
if ! git show-ref --quiet --verify "$local_ref" || ! git show-ref --quiet --verify "$upstream_ref"; then
return 1
fi
count=$(git rev-list --count "$local_ref..$upstream_ref") || return 2
[ "${count:-0}" -gt 0 ]
}
changed_local_branches() {
local branches
local branch
local upstream
local has_new_commits_status
branches=$(git for-each-ref --format='%(refname:strip=2)' refs/heads) || return 1
while read -r branch; do
if [ -z "$branch" ]; then
continue
fi
if ! branch_exists_on_origin "$branch"; then
continue
fi
if ! branch_matches_current_worktree_branch "$branch"; then
continue
fi
upstream=$(git for-each-ref --format='%(upstream)' "refs/heads/$branch") || return 1
if [ -n "$upstream" ]; then
if has_new_commits "refs/heads/$branch" "$upstream"; then
echo "$branch"
else
has_new_commits_status=$?
if [ "$has_new_commits_status" -ne 1 ]; then
return "$has_new_commits_status"
fi
fi
elif git show-ref --quiet --verify "refs/remotes/origin/$branch"; then
if has_new_commits "refs/heads/$branch" "refs/remotes/origin/$branch"; then
echo "$branch"
else
has_new_commits_status=$?
if [ "$has_new_commits_status" -ne 1 ]; then
return "$has_new_commits_status"
fi
fi
fi
done <<<"$branches"
}
recent_new_origin_branches() {
local cutoff
local branches
local branch
local commit_date
if [ "$stay_on_current_branch" = true ]; then
return 0
fi
cutoff=$(new_branch_commit_max_age_cutoff_epoch)
branches=$(git for-each-ref --sort=-committerdate --format='%(refname:strip=3) %(committerdate:unix)' refs/remotes/origin) || return 1
while read -r branch commit_date; do
if [ "$branch" = "HEAD" ]; then
continue
fi
if [ -z "$branch" ]; then
continue
fi
if git show-ref --quiet --verify "refs/heads/$branch"; then
continue
fi
if [ "$commit_date" -lt "$cutoff" ]; then
if ! grep -Fxq -- "$branch" "$reported_skipped_new_branches"; then
echo "$branch" >>"$reported_skipped_new_branches"
echo "skipping new origin branch $branch: latest commit is older than $new_branch_commit_max_age" >&2
fi
continue
fi
echo "$branch"
done <<<"$branches"
}
next_branch_to_build() {
local branches
branches=$(
changed_local_branches || exit 1
recent_new_origin_branches || exit 1
) || return 1
if [ -n "$branches" ]; then
printf '%s\n' "$branches" | awk '!seen[$0]++' | head -n1
fi
}
auto_build_check() {
[ -n "$auto_build_branches" ] || return 0
local today now_hhmm matched_slot state_file state_dir
today=$(date -u +%Y-%m-%d)
now_hhmm=$(date -u +%H:%M)
# Find the latest configured slot at or before current UTC time.
matched_slot=""
local IFS=';'
local slot
for slot in $auto_build_times; do
if [[ ! "$slot" =~ ^[0-2][0-9]:[0-5][0-9]$ ]]; then
echo "WARNING: skipping invalid auto-build time slot: '$slot'; expected HH:MM." >&2
continue
fi
[[ ! "$slot" > "$now_hhmm" ]] && matched_slot="$slot"
done
[ -n "$matched_slot" ] || return 0
state_file=$(auto_builds_state_file)
state_dir=$(dirname "$state_file")
mkdir -p "$state_dir"
local branch
for branch in $auto_build_branches; do
if grep -qF "${branch}"$'\t'"${today}"$'\t'"${matched_slot}" "$state_file" 2>/dev/null; then
continue
fi
printf '%s\t%s\t%s\n' "$branch" "$today" "$matched_slot" >> "$state_file"
echo "auto build scheduled: $branch (slot $matched_slot)" >&2
echo "$branch"
return 0
done
}
retry_origin_change_check() {
local branch
while true; do
if branch=$(next_pending_build_branch); then
echo "$branch"
return 0
fi
retry_fetch_origin
if branch=$(next_branch_to_build); then
echo "$branch"
return 0
fi
echo "checking for new branches or commits failed; retrying in 10s ..." >&2
sleep 10
done
}
restart_interrupted_or_running_builds() {
local branch
local has_restartable_build=false
while IFS= read -r branch; do
if ! branch_matches_current_worktree_branch "$branch"; then
continue
fi
has_restartable_build=true
echo "Restarting pending, interrupted, or stale running build: $branch"
checkout_and_build "$branch" || return 1
done < <(restartable_build_branches)
if [ "$has_restartable_build" = true ]; then
print_build_results
else
echo "No restartable pending, interrupted, or stale running builds found."
fi
}
retry_failed_builds() {
local branch
local has_failed_build=false
if [ "$retry_failed_builds_requested" != true ]; then
return 0
fi
while IFS= read -r branch; do
if ! branch_matches_current_worktree_branch "$branch"; then
continue
fi
has_failed_build=true
echo "Retrying failed build: $branch"
checkout_and_build "$branch" || return 1
done < <(failed_build_branches)
if [ "$has_failed_build" = true ]; then
print_build_results
fi
}
for arg in "$@"; do
case "$arg" in
--install)
install_after_pull=true
;;
--systemd)
install_systemd_after_install=true
systemd_command_given=true
;;
--systemd:start|--systemd:stop|--systemd:reload|--systemd:status|--systemd:log|--systemd:watch|--systemd:enable|--systemd:disable)
systemd_action="${arg#--systemd:}"
systemd_command_given=true
;;
--pull)
pull_current_branch=true
;;
--docker)
use_docker_build=true
;;
--http)
use_artifact_http_server=true
;;
--open)
open_artifact_frontend=true
use_artifact_http_server=true
;;
--nginx)
use_artifact_nginx=true
use_artifact_http_server=true
;;
--retry)
retry_failed_builds_requested=true
;;
--stay)
stay_on_current_branch=true
;;
-h|--help)
usage
exit 0
;;
-*)
echo "Unknown option: $arg"
usage
exit 1
;;
*)
branches_to_build+=("$(normalize_branch_name "$arg")")
;;
esac
done
if [ -n "$systemd_action" ]; then
run_systemd_action "$systemd_action"
exit $?
fi
if [ "$install_systemd_after_install" = true ] && [ "$install_after_pull" != true ]; then
echo "ERROR: --systemd requires --install." >&2
exit 1
fi
validate_stay_on_current_branch || exit 1
echo "$tool_name version $script_version"
detect_gitea_repo
configure_artifact_nginx_defaults
validate_artifact_build_retention_per_branch
validate_new_branch_commit_max_age
validate_auto_build_times
if [ "$pull_current_branch" = true ] || [ "$install_after_pull" != true ] || [ "${#branches_to_build[@]}" -gt 0 ]; then
validate_gitea_git_credentials || exit 1
fi
if gitea_status_enabled; then
echo "Gitea build status: ${gitea_base_url%/}/$gitea_owner/$gitea_repo ($gitea_status_context)"
elif [ -n "$gitea_token" ]; then
echo "WARNING: Gitea build status disabled because base URL, owner, repo, curl, or python3 is missing." >&2
fi
if [ "$pull_current_branch" = true ]; then
pull_current_branch_from_origin || exit 1
fi
if [ "$install_after_pull" = true ]; then
forwarded_args=()
for arg in "$@"; do
case "$arg" in
--install|--pull|--systemd)
;;
--systemd:*)
;;
*)
forwarded_args+=("$arg")
;;
esac
done
install_to_bin
if [ "$install_systemd_after_install" = true ]; then
install_systemd_service "$(dirname "$installed_script_path")/$systemd_unit_name"
fi
if [ "${#forwarded_args[@]}" -gt 0 ]; then
GITTALLY_BIN_FORWARD=true exec "$installed_script_path" "${forwarded_args[@]}"
fi
exit 0
fi
if [ "$pull_current_branch" = true ]; then
exit 0
fi
if [ "$systemd_command_given" = true ]; then
exit 0
fi
retry_fetch_origin
mark_running_builds_interrupted || exit 1
start_artifact_http_server
open_artifact_frontend_if_requested
start_artifact_nginx
if [ "${#branches_to_build[@]}" -eq 0 ]; then
restart_interrupted_or_running_builds || exit 1
retry_failed_builds || exit 1
fi
for branch in "${branches_to_build[@]}"; do
checkout_requested_branch "$branch" || exit 1
done
start_resource_monitor
write_system_page || true
write_env_page || true
echo "$tool_name version $script_version: service ready"
while true; do
branch_to_build=$(auto_build_check)
if [ -z "$branch_to_build" ]; then
branch_to_build=$(retry_origin_change_check)
fi
if [ -n "$branch_to_build" ]; then
checkout_and_build "$branch_to_build"
continue
fi
# wait 10s with a little animation
echo -e -n "\r\033[K waiting for changes (/) ..."
sleep 2
echo -e -n "\r\033[K waiting for changes (-) ..."
sleep 2
echo -e -n "\r\033[K waiting for changes (\) ..."
sleep 2
echo -e -n "\r\033[K waiting for changes (|) ..."
sleep 2
echo -e -n "\r\033[K waiting for changes ( ) ... "
sleep 2
echo -e -n "\r\033[K checking for changes"
done