#!/usr/bin/env bash # A small and opinionated continuous integration tool for projects that do not have the hardware budget or operational staff for large CI/CD systems. # Made to run locally or in Designed for Hostsharing Container Server environments with Docker (Podman not tested yet). # # Create the config file with (--env), edit and source, # then run the script in the root of a git working tree, e.g. with --install. # The script waits for new commits on any branch on origin, then checks it out, and runs a command to build+test the branch. # Call with -h or --help for more details. # # This script was mostly vibe-coded to replace the clumsy configuration by code of Jenkins. # # MIT License # # Copyright (c) 2026 Michael Hönnig # # Permission is hereby granted, free of charge, to any person obtaining a copy # of this software and associated documentation files (the "Software"), to deal # in the Software without restriction, including without limitation the rights # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell # copies of the Software, and to permit persons to whom the Software is # furnished to do so, subject to the following conditions: # # The above copyright notice and this permission notice shall be included in all # copies or substantial portions of the Software. # # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE # SOFTWARE. script_version="0.7.6" script_path=$(realpath "${BASH_SOURCE[0]}") script_name=$(basename "${BASH_SOURCE[0]}") tool_name="GitTally" installed_script_path= 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_auth_gitea_base_url='https://git.example.org' default_artifact_auth_image='quay.io/oauth2-proxy/oauth2-proxy:v7.13.0' default_artifact_auth_http_port='4180' default_artifact_auth_email_domains='*' 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' default_artifact_auth_client_id='gitea-oauth-client-id' default_artifact_auth_client_secret='gitea-oauth-client-secret' default_artifact_auth_cookie_secret='generate-a-random-cookie-secret' default_artifact_auth_cookie_domain='ci.example.org' default_artifact_auth_container_name='gittally-auth-example-repo' 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" <"$env_path" </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" <&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='' 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_server_host 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 artifact_certbot_env local artifact_auth_mode local artifact_auth_gitea_base_url local artifact_auth_client_id local artifact_auth_client_secret local artifact_auth_cookie_secret local artifact_auth_cookie_domain local artifact_auth_image local artifact_auth_container_name local artifact_auth_http_port local artifact_auth_email_domains local impressum_url local gitea_token local gitea_status_context local gitea_status_target_url 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_server_host=$(config_value GITTALLY_ARTIFACT_SERVER_HOST HSADMIN_NG_ARTIFACT_SERVER_HOST "") artifact_http_server_port="$artifact_server_port" artifact_http_server_bind_address="$artifact_server_bind_address" artifact_http_server_host="$artifact_server_host" 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 "") artifact_certbot_env=$(config_value GITTALLY_ARTIFACT_CERTBOT_ENV HSADMIN_NG_ARTIFACT_CERTBOT_ENV "") artifact_auth_mode=$(config_value GITTALLY_ARTIFACT_AUTH_MODE "" "") artifact_auth_gitea_base_url=$(config_value GITTALLY_ARTIFACT_AUTH_GITEA_BASE_URL "" "") artifact_auth_client_id=$(config_value GITTALLY_ARTIFACT_AUTH_CLIENT_ID "" "") artifact_auth_client_secret=$(config_value GITTALLY_ARTIFACT_AUTH_CLIENT_SECRET "" "") artifact_auth_cookie_secret=$(config_value GITTALLY_ARTIFACT_AUTH_COOKIE_SECRET "" "") artifact_auth_cookie_domain=$(config_value GITTALLY_ARTIFACT_AUTH_COOKIE_DOMAIN "" "") artifact_auth_image=$(config_value GITTALLY_ARTIFACT_AUTH_IMAGE "" "$default_artifact_auth_image") artifact_auth_container_name=$(config_value GITTALLY_ARTIFACT_AUTH_CONTAINER_NAME "" "") artifact_auth_http_port=$(config_value GITTALLY_ARTIFACT_AUTH_HTTP_PORT "" "$default_artifact_auth_http_port") artifact_auth_email_domains=$(config_value GITTALLY_ARTIFACT_AUTH_EMAIL_DOMAINS "" "$default_artifact_auth_email_domains") 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/" elif [ -n "$artifact_server_host" ]; then artifact_public_base_url="http://$artifact_server_host:$artifact_server_port/" 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") gitea_status_target_url=$(config_value GITTALLY_GITEA_STATUS_TARGET_URL HSADMIN_NG_GITEA_STATUS_TARGET_URL "") detect_gitea_repo_from_origin_url if [ -z "$gitea_git_username" ]; then gitea_git_username=$(detect_git_username_from_origin_url) fi if [ -z "$artifact_auth_gitea_base_url" ]; then artifact_auth_gitea_base_url="${gitea_base_url:-$default_artifact_auth_gitea_base_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/' 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 if [ -z "$artifact_auth_container_name" ]; then artifact_auth_container_name="gittally-auth-$(printf '%s' "$default_repository_simple_name" | sed 's#[^[:alnum:]_.-]#-#g')" 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_SERVER_HOST "$artifact_server_host" 'Host name or address printed in local artifact server URLs.' "" 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_var GITTALLY_ARTIFACT_CERTBOT_ENV "$artifact_certbot_env" 'Additional certbot arguments, for example --staging.' "" print_env_section "Artifact website Gitea login" print_env_var GITTALLY_ARTIFACT_AUTH_MODE "$artifact_auth_mode" 'Frontend auth mode for --nginx. Use gitea-oauth2 to require a Gitea browser login.' "" print_env_var GITTALLY_ARTIFACT_AUTH_GITEA_BASE_URL "$artifact_auth_gitea_base_url" 'Gitea base URL used only for browser login to the artifact website.' "$default_artifact_auth_gitea_base_url" print_env_var GITTALLY_ARTIFACT_AUTH_CLIENT_ID "$artifact_auth_client_id" 'OAuth2 client ID registered in Gitea for the artifact website.' "$default_artifact_auth_client_id" print_env_var GITTALLY_ARTIFACT_AUTH_CLIENT_SECRET "$artifact_auth_client_secret" 'OAuth2 client secret registered in Gitea for the artifact website.' "$default_artifact_auth_client_secret" print_env_var GITTALLY_ARTIFACT_AUTH_COOKIE_SECRET "$artifact_auth_cookie_secret" 'Random oauth2-proxy cookie secret used to protect browser sessions.' "$default_artifact_auth_cookie_secret" print_env_var GITTALLY_ARTIFACT_AUTH_COOKIE_DOMAIN "$artifact_auth_cookie_domain" 'Optional cookie domain for browser sessions.' "$default_artifact_auth_cookie_domain" print_env_var GITTALLY_ARTIFACT_AUTH_IMAGE "$artifact_auth_image" 'OAuth2 proxy Docker image used for Gitea browser login.' "$default_artifact_auth_image" print_env_var GITTALLY_ARTIFACT_AUTH_CONTAINER_NAME "$artifact_auth_container_name" 'Docker container name for the artifact website auth proxy.' "$default_artifact_auth_container_name" print_env_var GITTALLY_ARTIFACT_AUTH_HTTP_PORT "$artifact_auth_http_port" 'Internal HTTP port used by the artifact website auth proxy.' "$default_artifact_auth_http_port" print_env_var GITTALLY_ARTIFACT_AUTH_EMAIL_DOMAINS "$artifact_auth_email_domains" 'Allowed email domains for logged-in Gitea users. Use * to allow any Gitea login.' "$default_artifact_auth_email_domains" 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" print_env_var GITTALLY_GITEA_STATUS_TARGET_URL "$gitea_status_target_url" 'Fixed status target URL. Leave empty to link to archived build artifacts.' "" } 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_auth_container_started=false artifact_nginx_container_id= artifact_auth_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 if [ "${artifact_auth_container_started:-false}" = true ] && [ -n "${artifact_auth_container_id:-}" ]; then docker rm -f "$artifact_auth_container_id" >/dev/null 2>&1 || true artifact_auth_container_started=false artifact_auth_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_http_server_host=$(config_value GITTALLY_ARTIFACT_SERVER_HOST HSADMIN_NG_ARTIFACT_SERVER_HOST "") 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 "") artifact_certbot_env=$(config_value GITTALLY_ARTIFACT_CERTBOT_ENV HSADMIN_NG_ARTIFACT_CERTBOT_ENV "") artifact_auth_mode=$(config_value GITTALLY_ARTIFACT_AUTH_MODE "" "") artifact_auth_gitea_base_url=$(config_value GITTALLY_ARTIFACT_AUTH_GITEA_BASE_URL "" "") artifact_auth_client_id=$(config_value GITTALLY_ARTIFACT_AUTH_CLIENT_ID "" "") artifact_auth_client_secret=$(config_value GITTALLY_ARTIFACT_AUTH_CLIENT_SECRET "" "") artifact_auth_cookie_secret=$(config_value GITTALLY_ARTIFACT_AUTH_COOKIE_SECRET "" "") artifact_auth_cookie_domain=$(config_value GITTALLY_ARTIFACT_AUTH_COOKIE_DOMAIN "" "") artifact_auth_image=$(config_value GITTALLY_ARTIFACT_AUTH_IMAGE "" "$default_artifact_auth_image") artifact_auth_container_name=$(config_value GITTALLY_ARTIFACT_AUTH_CONTAINER_NAME "" "") artifact_auth_http_port=$(config_value GITTALLY_ARTIFACT_AUTH_HTTP_PORT "" "$default_artifact_auth_http_port") artifact_auth_email_domains=$(config_value GITTALLY_ARTIFACT_AUTH_EMAIL_DOMAINS "" "$default_artifact_auth_email_domains") 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") gitea_status_target_url=$(config_value GITTALLY_GITEA_STATUS_TARGET_URL HSADMIN_NG_GITEA_STATUS_TARGET_URL "") 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_SERVER_HOST to override the host printed in artifact server URLs." 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 "Set GITTALLY_ARTIFACT_CERTBOT_ENV for extra certbot args, for example --staging." echo "Set GITTALLY_ARTIFACT_AUTH_MODE=gitea-oauth2 to require Gitea browser login for the nginx artifact website." echo "Set GITTALLY_ARTIFACT_AUTH_GITEA_BASE_URL to choose the Gitea login base URL; default: detected Gitea URL or https://dev.hostsharing.net." echo "Set GITTALLY_ARTIFACT_AUTH_CLIENT_ID, GITTALLY_ARTIFACT_AUTH_CLIENT_SECRET, and GITTALLY_ARTIFACT_AUTH_COOKIE_SECRET for OAuth2 login." echo "Set GITTALLY_ARTIFACT_AUTH_EMAIL_DOMAINS to restrict logged-in Gitea users by email domain; default: *." 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 "Set GITTALLY_GITEA_STATUS_TARGET_URL to override the status target URL." 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)" } artifact_auth_enabled() { [ "$artifact_auth_mode" = gitea-oauth2 ] } validate_artifact_auth_mode() { case "$artifact_auth_mode" in ""|none) artifact_auth_mode= ;; gitea|gitea-oauth2) artifact_auth_mode=gitea-oauth2 ;; *) echo "WARNING: invalid GITTALLY_ARTIFACT_AUTH_MODE: $artifact_auth_mode; artifact website auth disabled." >&2 artifact_auth_mode= ;; esac } 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" elif [ -n "$artifact_http_server_host" ]; then artifact_nginx_server_name="$artifact_http_server_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_auth_mode if [ -z "$artifact_auth_gitea_base_url" ]; then artifact_auth_gitea_base_url="${gitea_base_url:-$default_artifact_auth_gitea_base_url}" fi if [ -z "$artifact_auth_container_name" ]; then artifact_auth_container_name="gittally-auth-$(safe_container_name_part "$(repository_simple_name)")" 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 "$gitea_status_target_url" ]; then echo "$gitea_status_target_url" elif [ -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' } 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 '
  • %s
  • \n' \ "$(printf '%s' "$href" | html_escape)" \ "$(printf '%s' "$label" | html_escape)" \ >>"$index_file" } html_copy_button() { local value="$1" local label="$2" printf '' \ "$(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 ' \n' "$(printf '%s' "$href" | html_escape)" printf ' \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' 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 '
    ' printf 'gitTally v%s (env) ' \ "$(printf '%s' "$about_href" | html_escape)" \ "$(printf '%s' "$script_version" | html_escape)" printf -- '- (c) Michael Hönnig, 2026 ' printf -- '- Licensed under the MIT License ' "$(printf '%s' "$license_href" | html_escape)" printf -- '- Impressum (Legal Disclosure)' "$(printf '%s' "$impressum_url" | html_escape)" printf '
    \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 gitTally - About

    About gitTally

    gitTally 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.

    It is a Hostsharing community project, not an official project of Hostsharing eG.

    Intention

    • Configuration is environment-driven, with no UI settings, so installations remain easy to bootstrap and repeatable.
    • It can be started right within any git working tree, even locally on the developers computer or on a spare computer.
    • Designed for Hostsharing Container Server environments with Docker or Podman.

    Operating Model

    gitTally watches branches, checks out new commits, runs a configurable build command, then archives build output for later inspection. GitEA integration can publish commit status and protect the artifact website through OAuth2 login.

    • Build run directly in the environment or optionally in a Docker container.
    • The build command is configurable, so gitTally is build-system agnostic.
    • Build-status for the branches are kept locally and are pushed to a GitEA instance.

    Runtime Environment

    The goal is low-cost operation without a dedicated VM per project. gitTally is meant to run as a normal Linux user without root privileges.

    • Can also run on a local computer for small projects or personal workflows.
    • Supports systemd user services for unattended operation.
    • Provides optional nginx reverse-proxy support with Let's Encrypt certificates.

    Web Interface

    The web interface exposes the information needed to inspect current and past builds, while staying simple and static.

    • Latest, branches, builds, and current-build views.
    • Archived stdout, stderr, and reports for each build.
    • Optional cancellation of the currently running build.
    • Static HTML served by the built-in artifact HTTP server or through nginx.

    Roadmap

    gitTally is currently a bash script, developed (mostly vibe-coded) with IntelliJ IDEA AI Chat, mainly powered by Codex and GPT-5.5 It may later get refactored to maintainable code in Kotlin or Python.

    Planned features: Support for ...

    • Separate the builder from the watcher, so that new branches can get detected during a build.
    • GitEA PRs including green build as quality-gate for merging to master/main,
    • separate build-command for special branches like master/main.,
    • Docker-based deployments for branches,
    • rootles Podman environments.

    Download the current gitTally script.

    EOF write_html_footer "$index_file" { printf '\n' printf '\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 '\n' printf '\n' printf '\n' printf ' \n' printf ' \n' printf ' GitTally - MIT License\n' } >"$index_file" write_html_favicon_links "$index_file" { printf ' \n' printf '\n' printf '\n' printf '
    \n' printf '

    The MIT License

    \n' printf ' \n' printf '
    \n' printf '

    Copyright 2026 Michael Hönnig

    \n' printf '

    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:

    \n' printf '

    The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.

    \n' printf '

    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.

    \n' printf '
    \n' printf '
    \n' } >>"$index_file" write_html_footer "$index_file" { printf '\n' printf '\n' } >>"$index_file" } write_build_artifact_view_toggle() { local index_file="$1" local current_view_label="$2" local right_html="${3:-}" printf '
    \n' >>"$index_file" printf ' \n' >>"$index_file" if [ -n "$right_html" ]; then printf '
    %s
    \n' "$right_html" >>"$index_file" fi printf '
    \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 '
    ' "$(basename "$index_file")") mkdir -p "$artifacts_root" || return 1 { printf '\n' printf '\n' printf '\n' printf ' \n' printf ' \n' printf ' \n' printf ' \n' printf ' \n' printf ' %s\n' "$(printf '%s' "$page_title" | html_escape)" } >"$index_file" write_html_favicon_links "$index_file" { printf ' \n' printf '\n' printf '\n' printf '
    \n' printf '

    %s

    \n' "$(printf '%s' "$page_title" | html_escape)" } >>"$index_file" write_build_artifact_view_toggle "$index_file" "$current_view_label" "$reload_action_html" { printf '
    \n' printf ' \n' printf ' \n' printf ' \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 '%s%s' \ "$(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 '%s' \ "$(printf '%s' "$commit_url" | html_escape)" \ "$commit_cell") fi printf ' \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 ' \n' >>"$index_file" elif [ "$view" = "branches" ]; then printf ' \n' >>"$index_file" else printf ' \n' >>"$index_file" fi fi { printf ' \n' printf '
    StatusCommitDurationArtifactsActions
    %s%s%s%s%s%s%s' \ "$(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 '' \ "$(printf '%s' "$artifact_key" | html_escape)" \ >>"$index_file" else printf 'n/a' >>"$index_file" fi printf '
    ' >>"$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 '
    ' \ "$(printf '%s' "$branch" | html_escape)" \ "$(printf '%s' "$commit" | html_escape)" \ "$return_to" \ >>"$index_file" fi if [ -n "$artifact_key" ]; then printf '
    ' \ "$(printf '%s' "$branch" | html_escape)" \ "$(printf '%s' "$commit" | html_escape)" \ "$(printf '%s' "$artifact_key" | html_escape)" \ "$return_to" \ >>"$index_file" fi fi printf '
    No latest build results found.
    No local branches found.
    No builds archived yet.
    \n' printf '
    \n' printf '
    \n' } >>"$index_file" { printf ' \n' } >>"$index_file" write_html_footer "$index_file" { printf '\n' printf '\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 '
    ' "$(printf '%s' "$cancel_token" | html_escape)") fi fi mkdir -p "$artifacts_root" || return 1 { printf '\n' printf '\n' printf '\n' printf ' \n' printf ' \n' printf ' \n' printf ' \n' printf ' \n' printf ' %s\n' "$(printf '%s' "$page_title" | html_escape)" } >"$index_file" write_html_favicon_links "$index_file" { printf ' \n' printf '\n' printf '\n' printf '
    \n' printf '

    %s

    \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 '

    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 '

    \n' else printf '

    No build is currently running.

    \n' fi printf '
    Loading current.log...
    \n' printf '
    \n' printf ' \n' } >>"$index_file" write_html_footer "$index_file" { printf '\n' printf '\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", ] def fmt(value): if isinstance(value, (int, float)) and math.isfinite(value): return f"{value:.2f}" 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"{cpu_count} cores" if isinstance(cpu_count, int) else placeholder) values.append(fmt(data.get("ram_total_gib")) + " GiB" if isinstance(data.get("ram_total_gib"), (int, float)) 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 cpu_count ram_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" 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 \ cpu_count ram_total updated < <(system_page_snapshot "$artifacts_root/system.json") { printf '\n' printf '\n' printf '\n' printf ' \n' printf ' \n' printf ' \n' printf ' \n' printf ' \n' printf ' %s\n' "$(printf '%s' "$page_title" | html_escape)" } >"$index_file" write_html_favicon_links "$index_file" { printf ' \n' printf '\n' printf '\n' printf '
    \n' printf '

    %s

    \n' "$(printf '%s' "$page_title" | html_escape)" } >>"$index_file" write_build_artifact_view_toggle "$index_file" System { printf '
    \n' printf ' \n' printf ' \n' printf ' \n' printf ' \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 ' \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 ' \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 ' \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 ' \n' printf '
    MetricCurrentMinMaxAvg
    CPU used (cores)%s%s%s%s
    CPU idle (cores)%s%s%s%s
    RAM used (GiB)%s%s%s%s
    RAM free (GiB)%s%s%s%s
    \n' printf '
    \n' printf '

    CPU total: %s  ·  RAM total: %s  ·  Updated: %s(*: min/max/avg since script start)

    \n' \ "$(printf '%s' "$cpu_count" | html_escape)" "$(printf '%s' "$ram_total" | html_escape)" "$(printf '%s' "$updated" | html_escape)" printf '
    \n' printf ' \n' } >>"$index_file" write_html_footer "$index_file" { printf '\n' printf '\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 '\n' printf '\n' printf '\n' printf ' \n' printf ' \n' printf ' \n' printf ' %s\n' "$(printf '%s' "$page_title" | html_escape)" } >"$index_file" write_html_favicon_links "$index_file" { printf ' \n' printf '\n' printf '\n' printf '
    \n' printf '

    %s

    \n' "$(printf '%s' "$page_title" | html_escape)" } >>"$index_file" write_build_artifact_view_toggle "$index_file" Env { printf '
    \n' printf '
    %s
    \n' "$(printf '%s' "$env_text" | html_escape)" printf '
    \n' printf '
    \n' } >>"$index_file" write_html_footer "$index_file" { printf '\n' printf '\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 sleep 5 local total idle total_diff idle_diff cpu_used cpu_idle local ram_total_gib ram_used_gib ram_free_gib timestamp 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/1024/1024, used/1024/1024, avail/1024/1024 }' \ /proc/meminfo ) total_diff=$((total - prev_total)) idle_diff=$((idle - prev_idle)) if [ "$total_diff" -gt 0 ]; then cpu_used=$(LC_ALL=C awk "BEGIN {printf \"%.2f\", $cpu_count * ($total_diff - $idle_diff) / $total_diff}") cpu_idle=$(LC_ALL=C 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 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 while ((getline line < sf) > 0) { nf = split(line, f, " ") if (nf >= 13) { 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 printf "%d %.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 > 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}\n", rf, rf_min, rf_max, rf_sum/n }' > "${system_file}.tmp" && mv "${system_file}.tmp" "${system_file}" && write_system_page || true 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 [ -n "$artifact_http_server_host" ]; then echo "$artifact_http_server_host" return 0 fi 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"]*>.*?", old_index, re.IGNORECASE | re.DOTALL) if article_match: return article_match.group(0) main_match = re.search(r"]*>(.*?)", old_index, re.IGNORECASE | re.DOTALL) if main_match: return re.sub( r"\s*]*>.*?\s*", "", main_match.group(1), count=1, flags=re.IGNORECASE | re.DOTALL, ).strip() return "

    Could not extract artifact index content from the stored page.

    " 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"""{escaped_branch}""" else: branch_title_html = f"""{escaped_branch}""" escaped_version = html.escape(script_version) escaped_impressum_url = html.escape(impressum_url, quote=True) return f""" Build artifacts: {escaped_branch}

    Build artifacts: {branch_title_html}

    {content}
    """ 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*]*\bdata-artifact-key=\"" + re.escape(escaped_artifact_key) + r"\"[^>]*>.*?\n?", re.DOTALL, ) branch_row = ( " unknown" + "" + html.escape(branch) + "" + html.escape(commit[:12]) + "n/a
    " + "
    " + "
    \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"(\n)\s*()", r"""\1 No builds archived yet.\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"()[^<]*()" ) 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" local auth_proxy_url="http://$artifact_auth_container_name:$artifact_auth_http_port" if [ "$mode" = init ]; then cat >"$config_file" <"$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_docker_container_by_name "$artifact_auth_container_name" remove_gittally_containers_by_label nginx remove_gittally_containers_by_label auth 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[@]}") if artifact_auth_enabled; then docker_args+=(--link "$artifact_auth_container_name:$artifact_auth_container_name") fi container_id=$(docker "${docker_args[@]}" nginx) || return 1 artifact_nginx_container_id="$container_id" artifact_nginx_container_started=true } start_artifact_auth_proxy() { local public_base_url local redirect_url local gitea_base_url local container_id local -a docker_args local -a docker_label_args local -a email_domain_args=() local -a email_domains=() local -a cookie_domain_args=() local email_domain if ! artifact_auth_enabled; then return 0 fi if [ -z "$artifact_auth_client_id" ] || [ -z "$artifact_auth_client_secret" ] || [ -z "$artifact_auth_cookie_secret" ]; then echo "WARNING: cannot enable artifact website Gitea login because OAuth2 client id, client secret, or cookie secret is empty." >&2 return 1 fi if ! [[ "$artifact_auth_http_port" =~ ^[0-9]+$ ]] || [ "$artifact_auth_http_port" -lt 1 ] || [ "$artifact_auth_http_port" -gt 65535 ]; then echo "WARNING: invalid GITTALLY_ARTIFACT_AUTH_HTTP_PORT: $artifact_auth_http_port" >&2 return 1 fi public_base_url=$(normalize_base_url "${artifact_public_base_url:-https://$artifact_nginx_server_name/}") redirect_url="${public_base_url}oauth2/callback" gitea_base_url="${artifact_auth_gitea_base_url%/}" IFS=',' read -r -a email_domains <<<"$artifact_auth_email_domains" for email_domain in "${email_domains[@]}"; do if [ -n "$email_domain" ]; then email_domain_args+=(--email-domain "$email_domain") fi done if [ "${#email_domain_args[@]}" -eq 0 ]; then email_domain_args=(--email-domain '*') fi if [ -n "$artifact_auth_cookie_domain" ]; then cookie_domain_args=(--cookie-domain "$artifact_auth_cookie_domain") fi remove_docker_container_by_name "$artifact_auth_container_name" artifact_auth_container_started=false artifact_auth_container_id= docker_args=(run -d --name "$artifact_auth_container_name" --network bridge) mapfile -t docker_label_args < <(gittally_docker_label_args auth) docker_args+=("${docker_label_args[@]}" "$artifact_auth_image" --http-address=0.0.0.0:"$artifact_auth_http_port" --provider=github --provider-display-name=Gitea --client-id="$artifact_auth_client_id" --client-secret="$artifact_auth_client_secret" --cookie-secret="$artifact_auth_cookie_secret" --cookie-secure=true --redirect-url="$redirect_url" --login-url="$gitea_base_url/login/oauth/authorize" --redeem-url="$gitea_base_url/login/oauth/access_token" --validate-url="$gitea_base_url/api/v1/user/emails" --reverse-proxy=true --set-xauthrequest=true --skip-provider-button=true "${email_domain_args[@]}" "${cookie_domain_args[@]}") if ! container_id=$(docker "${docker_args[@]}"); then echo "WARNING: could not start artifact website Gitea auth proxy $artifact_auth_container_name." >&2 return 1 fi artifact_auth_container_id="$container_id" artifact_auth_container_started=true echo "Artifact website auth: Gitea login via $gitea_base_url" echo "Artifact website auth callback: $redirect_url" } 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 certbot_extra_args=() local -a email_args=() if [ -n "$artifact_certbot_env" ]; then read -r -a certbot_extra_args <<<"$artifact_certbot_env" fi 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 "${certbot_extra_args[@]}" 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[@]}" "${certbot_extra_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 if ! start_artifact_auth_proxy; then echo "WARNING: artifact nginx HTTPS proxy was not started because artifact website auth setup failed." >&2 return 0 fi 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 if ! start_artifact_auth_proxy; then echo "WARNING: artifact nginx HTTPS proxy was not started because artifact website auth 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 '
    \n' printf '

    Logs

    \n' printf '
      \n' } >"$index_file" printf '
    • Build command:
      %s
    • \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 '
    \n' printf '

    Build Artifacts

    \n' printf '
      \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 '
    • No artifact directories were produced by this build.
    • \n' >>"$index_file" fi { printf '
    \n' printf '
    \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