5971 lines
255 KiB
Bash
Executable File
5971 lines
255 KiB
Bash
Executable File
#!/usr/bin/env bash
|
||
# DEPRECATED: This script has been replaced by the Kotlin/Spring application in this repository.
|
||
# See docs/migration-from-legacy.md for the migration guide; the script is kept only as a behavioral reference.
|
||
#
|
||
# 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/&/\&/g' \
|
||
-e 's/</\</g' \
|
||
-e 's/>/\>/g' \
|
||
-e 's/"/\"/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: <strong id="info-cpu-count">%s</strong> · RAM total: <strong id="info-ram-total">%s</strong> · Disk total: <strong id="info-disk-total">%s</strong> · Updated: <span id="info-updated">%s</span> (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
|