From fdc659960d744214e4ad208c56465ffb7a044d87 Mon Sep 17 00:00:00 2001 From: Michael Hoennig Date: Tue, 9 Jun 2026 10:17:35 +0200 Subject: [PATCH] initial revision with vibe-coded shell script and new design document --- .idea/.gitignore | 10 + docs/GitTally-Konzept.md | 168 ++ legacy/.gitTally | 229 ++ legacy/gitTally | 6152 ++++++++++++++++++++++++++++++++++++++ 4 files changed, 6559 insertions(+) create mode 100644 .idea/.gitignore create mode 100644 docs/GitTally-Konzept.md create mode 100644 legacy/.gitTally create mode 100755 legacy/gitTally diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..30cf57e --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,10 @@ +# Default ignored files +/shelf/ +/workspace.xml +# Editor-based HTTP Client requests +/httpRequests/ +# Ignored default folder with query files +/queries/ +# Datasource local storage ignored files +/dataSources/ +/dataSources.local.xml diff --git a/docs/GitTally-Konzept.md b/docs/GitTally-Konzept.md new file mode 100644 index 0000000..1fd0ec9 --- /dev/null +++ b/docs/GitTally-Konzept.md @@ -0,0 +1,168 @@ +# GitTally – Konzept und Architekturübersicht + +## Motivation + +GitTally ist ein bewusst minimalistisches und stark opinionated Continuous-Integration-System (CI), das später um Continuous Delivery (CD) erweitert werden kann. + +Ziel ist es, die Komplexität klassischer CI-Systeme wie Jenkins erheblich zu reduzieren und stattdessen einen einfachen, nachvollziehbaren und git-zentrierten Ansatz zu verfolgen. + +## Grundprinzipien + +- Git wird immer verwendet. +- Builds laufen in Docker. +- Die Konfiguration erfolgt primär über YAML-Dateien. +- Eine Instanz verwaltet zunächst genau ein Repository. +- Build-Status werden an das Git-System zurückgemeldet. + +## Betriebsarten + +### CLI-Modus + +- Interaktive Nutzung +- Status anzeigen +- Konfiguration anzeigen +- Initialisierung durchführen + +### Server-Modus + +- HTTP-Weboberfläche +- Betrieb hinter Nginx möglich +- Dauerbetrieb auf einer VM oder einem Server + +## Architekturdiagramm + +```mermaid +flowchart LR + User[User] + + Git[(Git Repository)] + Gitea[(Gitea API)] + Docker[(Docker)] + + Storage[(Dateisystem
später SQLite)] + + GitMonitor[GitMonitor] + StatusService[StatusService] + BuildWorker[BuildWorker] + GitStatusPublisher[GitStatusPublisher] + + WebFrontend[WebFrontend] + CLIFrontend[CLIFrontend] + + User --> WebFrontend + User --> CLIFrontend + + GitMonitor --> Git + GitMonitor --> StatusService + + BuildWorker --> Git + BuildWorker --> Docker + BuildWorker --> Storage + BuildWorker --> StatusService + + StatusService --> Storage + + GitStatusPublisher --> Gitea + StatusService --> GitStatusPublisher + + WebFrontend --> StatusService + CLIFrontend --> StatusService +``` + +## Komponenten + +### GitMonitor + +- git fetch +- Branch-Erkennung +- Commit-Erkennung +- Erzeugen neuer Buildaufträge + +### StatusService + +- Verwaltung aller Builds +- Buildhistorie +- Recovery nach Neustarts +- Zentrale Wahrheit des Systems + +### BuildWorker + +- Pending-Build übernehmen +- Worktree anlegen +- Commit auschecken +- Build starten +- Artefakte sammeln +- Worktree entfernen + +### GitStatusPublisher + +- Pending melden +- Success melden +- Failure melden + +### WebFrontend + +- Status anzeigen +- Historie anzeigen +- Artefakte anbieten +- Builds neu starten + +### CLIFrontend + +- Status anzeigen +- Konfiguration anzeigen +- Initialisierung durchführen + +## Buildmodell + +### Commit-basierte Builds + +GitTally baut immer einen konkreten Commit und niemals nur einen Branchnamen. + +### Worktree pro Build + +Jeder Build erhält einen eigenen temporären Worktree. + +## Konfigurationsmodell + +1. Eingebaute Defaults +2. Globale Server-Konfiguration +3. Repository-Installation (.git/gittally/config.yml) +4. Projektkonfiguration (.gittally.yml) +5. Branchprofile + +## Bootstrapping + +### Voraussetzungen + +- Java Runtime +- Git Repository +- Ausgecheckter Workspace +- GitTally JAR + +### Initialisierung + +```bash +java -jar gittally.jar init +``` + +### Serverstart + +```bash +java -jar gittally.jar server +``` + +### Konfigurationsanzeige + +```bash +java -jar gittally.jar config:print +java -jar gittally.jar config:print --full +``` + +## Erweiterungen + +- Deployment / CD +- SQLite statt Dateisystem +- Mehrere BuildWorker +- Multi-Repository-Verwaltung +- Weitere Git-Plattformen diff --git a/legacy/.gitTally b/legacy/.gitTally new file mode 100644 index 0000000..2cee07f --- /dev/null +++ b/legacy/.gitTally @@ -0,0 +1,229 @@ +# Environment for GitTally +# +# Save and source this output before starting the script, for example: +# gitTally --env > .gittally.env +# . .gittally.env + +# ================================================================================ +# Installation +# -------------------------------------------------------------------------------- + +# Target directory used by --install. +# default GITTALLY_INSTALL_DIR=$HOME/bin +export GITTALLY_INSTALL_DIR=$HOME/gitTally + +# ================================================================================ +# Build command +# -------------------------------------------------------------------------------- + +# Shell command executed for each build. The branch name is available as $branch. +# default GITTALLY_BUILD_COMMAND='./gradlew --console=plain --no-daemon --no-build-cache --rerun-tasks test' +export GITTALLY_BUILD_COMMAND='./gradlew --console=plain --no-daemon --no-build-cache --rerun-tasks migrationTest -x unitTest -x test -x check -x pitest -x dependencyCheckAnalyze' + +# Shell command executed before a non-Docker build and before preparing a Docker workspace. +# default GITTALLY_BUILD_CLEAN_COMMAND='rm -rf build' +export GITTALLY_BUILD_CLEAN_COMMAND='rm -rf build' + +# Report directories copied into artifacts. Separate multiple paths with ';'. +# default GITTALLY_BUILD_ARTEFACT_DIRS='build/reports' +export GITTALLY_BUILD_ARTEFACT_DIRS='build/reports;build/doc' + +# Artifact filename for captured build stdout. +# default GITTALLY_BUILD_STDOUT_LOG='build.stdout.log' +export GITTALLY_BUILD_STDOUT_LOG='build.stdout.log' + +# Artifact filename for captured build stderr. +# default GITTALLY_BUILD_STDERR_LOG='build.stderr.log' +export GITTALLY_BUILD_STDERR_LOG='build.stderr.log' + +# Maximum age for the latest commit on new origin branches. Use h/d suffix. +# default GITTALLY_NEW_BRANCH_COMMIT_MAX_AGE='3d' +# export GITTALLY_NEW_BRANCH_COMMIT_MAX_AGE='3d' + +# ================================================================================ +# Docker build runtime +# -------------------------------------------------------------------------------- + +# Docker image used when --docker is enabled. +# default GITTALLY_BUILD_DOCKER_IMAGE='hsadmin-ng-build-env:latest' +export GITTALLY_BUILD_DOCKER_IMAGE='hsadmin-ng-build-env:latest' + +# Dockerfile used to build the image when it does not exist locally. +# default GITTALLY_BUILD_DOCKERFILE='Jenkins/jenkins-agent/Dockerfile' +export GITTALLY_BUILD_DOCKERFILE='Jenkins/jenkins-agent/Dockerfile' + +# Docker build context used with GITTALLY_BUILD_DOCKERFILE. +# default GITTALLY_BUILD_DOCKER_CONTEXT='Jenkins/jenkins-agent' +export GITTALLY_BUILD_DOCKER_CONTEXT='Jenkins/jenkins-agent' + +# Docker network mode for build containers. +# default GITTALLY_BUILD_DOCKER_NETWORK='host' +export GITTALLY_BUILD_DOCKER_NETWORK='host' + +# Command run inside the build container to verify Docker access. +# default GITTALLY_BUILD_DOCKER_PREFLIGHT_COMMAND='docker version' +export GITTALLY_BUILD_DOCKER_PREFLIGHT_COMMAND='docker version' + +# Additional environment assignments passed to the build container, separated by spaces. +# default GITTALLY_BUILD_DOCKER_ENV='TESTCONTAINERS_RYUK_DISABLED=true' +export GITTALLY_BUILD_DOCKER_ENV='TESTCONTAINERS_RYUK_DISABLED=true' + +# Java tool options added for Docker and Testcontainers defaults. +# default GITTALLY_BUILD_DOCKER_JAVA_TOOL_OPTIONS='-Ddocker.client.strategy=org.testcontainers.dockerclient.UnixSocketClientProviderStrategy -Dtestcontainers.docker.socket.override=/var/run/docker.sock' +export GITTALLY_BUILD_DOCKER_JAVA_TOOL_OPTIONS='-Ddocker.client.strategy=org.testcontainers.dockerclient.UnixSocketClientProviderStrategy -Dtestcontainers.docker.socket.override=/var/run/docker.sock' + +# ================================================================================ +# Artifact server +# -------------------------------------------------------------------------------- + +# Preferred HTTP port for serving archived build artifacts. +# default GITTALLY_ARTIFACT_SERVER_PORT='18080' +export GITTALLY_ARTIFACT_SERVER_PORT='18080' + +# Bind address for the artifact HTTP server. +# default GITTALLY_ARTIFACT_SERVER_BIND_ADDRESS='0.0.0.0' +export GITTALLY_ARTIFACT_SERVER_BIND_ADDRESS='0.0.0.0' + +# Host name or address printed in local artifact server URLs. +# default GITTALLY_ARTIFACT_SERVER_HOST='' +export GITTALLY_ARTIFACT_SERVER_HOST='' + +# Public base URL used for artifact links and Gitea status target URLs. +# default GITTALLY_ARTIFACT_PUBLIC_BASE_URL='https://ci.example.org/' +export GITTALLY_ARTIFACT_PUBLIC_BASE_URL='https://vm2176.hostsharing.net/' + +# Retained builds per branch. Use a count, or h/d suffix for age based retention. +# default GITTALLY_ARTIFACT_BUILD_RETENTION_PER_BRANCH='3' +export GITTALLY_ARTIFACT_BUILD_RETENTION_PER_BRANCH='3' + +# ================================================================================ +# Nginx and certificates +# -------------------------------------------------------------------------------- + +# Public server name for the nginx and certificate setup. +# default GITTALLY_ARTIFACT_NGINX_SERVER_NAME='ci.example.org' +export GITTALLY_ARTIFACT_NGINX_SERVER_NAME='vm2176.hostsharing.net' + +# Host HTTP port published by the nginx container. +# default GITTALLY_ARTIFACT_NGINX_HTTP_PORT='8080' +export GITTALLY_ARTIFACT_NGINX_HTTP_PORT='8080' + +# Host HTTPS port published by the nginx container. +# default GITTALLY_ARTIFACT_NGINX_HTTPS_PORT='8443' +export GITTALLY_ARTIFACT_NGINX_HTTPS_PORT='8443' + +# Host name nginx uses to reach the artifact HTTP server. +# default GITTALLY_ARTIFACT_NGINX_UPSTREAM_HOST='ci.example.org' +export GITTALLY_ARTIFACT_NGINX_UPSTREAM_HOST='vm2176.hostsharing.net' + +# Docker container name for the nginx reverse proxy. +# default GITTALLY_ARTIFACT_NGINX_CONTAINER_NAME='gittally-nginx-example-repo' +export GITTALLY_ARTIFACT_NGINX_CONTAINER_NAME='gittally-nginx-hsadmin-ng' + +# Persistent state directory for nginx config, logs, and certificate data. +# default GITTALLY_ARTIFACT_NGINX_STATE_DIR=${XDG_STATE_HOME:-$HOME/.local/state}/gittally/nginx/ +# export GITTALLY_ARTIFACT_NGINX_STATE_DIR='' + +# Email address used when registering Lets Encrypt certificates. +# default GITTALLY_ARTIFACT_LETSENCRYPT_EMAIL='admin@example.org' +export GITTALLY_ARTIFACT_LETSENCRYPT_EMAIL='' + +# Additional certbot arguments, for example --staging. +# default GITTALLY_ARTIFACT_CERTBOT_ENV='' +export GITTALLY_ARTIFACT_CERTBOT_ENV='' + +# ================================================================================ +# Artifact website Gitea login +# -------------------------------------------------------------------------------- + +# Frontend auth mode for --nginx. Use gitea-oauth2 to require a Gitea browser login. +# default GITTALLY_ARTIFACT_AUTH_MODE='' +export GITTALLY_ARTIFACT_AUTH_MODE='' + +# Gitea base URL used only for browser login to the artifact website. +# default GITTALLY_ARTIFACT_AUTH_GITEA_BASE_URL='https://git.example.org' +export GITTALLY_ARTIFACT_AUTH_GITEA_BASE_URL='https://dev.hostsharing.net' + +# OAuth2 client ID registered in Gitea for the artifact website. +# default GITTALLY_ARTIFACT_AUTH_CLIENT_ID='gitea-oauth-client-id' +export GITTALLY_ARTIFACT_AUTH_CLIENT_ID='' + +# OAuth2 client secret registered in Gitea for the artifact website. +# default GITTALLY_ARTIFACT_AUTH_CLIENT_SECRET='gitea-oauth-client-secret' +export GITTALLY_ARTIFACT_AUTH_CLIENT_SECRET='' + +# Random oauth2-proxy cookie secret used to protect browser sessions. +# default GITTALLY_ARTIFACT_AUTH_COOKIE_SECRET='generate-a-random-cookie-secret' +export GITTALLY_ARTIFACT_AUTH_COOKIE_SECRET='' + +# Optional cookie domain for browser sessions. +# default GITTALLY_ARTIFACT_AUTH_COOKIE_DOMAIN='ci.example.org' +export GITTALLY_ARTIFACT_AUTH_COOKIE_DOMAIN='' + +# OAuth2 proxy Docker image used for Gitea browser login. +# default GITTALLY_ARTIFACT_AUTH_IMAGE='quay.io/oauth2-proxy/oauth2-proxy:v7.13.0' +export GITTALLY_ARTIFACT_AUTH_IMAGE='quay.io/oauth2-proxy/oauth2-proxy:v7.13.0' + +# Docker container name for the artifact website auth proxy. +# default GITTALLY_ARTIFACT_AUTH_CONTAINER_NAME='gittally-auth-example-repo' +export GITTALLY_ARTIFACT_AUTH_CONTAINER_NAME='gittally-auth-hsadmin-ng' + +# Internal HTTP port used by the artifact website auth proxy. +# default GITTALLY_ARTIFACT_AUTH_HTTP_PORT='4180' +export GITTALLY_ARTIFACT_AUTH_HTTP_PORT='4180' + +# Allowed email domains for logged-in Gitea users. Use * to allow any Gitea login. +# default GITTALLY_ARTIFACT_AUTH_EMAIL_DOMAINS='*' +export GITTALLY_ARTIFACT_AUTH_EMAIL_DOMAINS='*' + +# ================================================================================ +# Legal +# -------------------------------------------------------------------------------- + +# URL for the Impressum (Legal Disclosure) link in the footer. +# default GITTALLY_IMPRESSUM_URL='https://example.org/imprint.html' +export GITTALLY_IMPRESSUM_URL='https://michael.hoennig.de/imprint.html' + +# ================================================================================ +# Auto builds +# -------------------------------------------------------------------------------- + +# Colon-separated list of branches to rebuild automatically. Leave empty to disable auto builds. +# default GITTALLY_AUTO_BUILD_BRANCHES='' +export GITTALLY_AUTO_BUILD_BRANCHES='master;main;mihoe/introduce-gitTally-ci' + +# 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 GITTALLY_AUTO_BUILD_TIMES='02:00' +export GITTALLY_AUTO_BUILD_TIMES='03:30' + +# ================================================================================ +# Gitea +# -------------------------------------------------------------------------------- + +# Base URL of the Gitea instance. +# default GITTALLY_GITEA_BASE_URL='https://git.example.org' +export GITTALLY_GITEA_BASE_URL='https://dev.hostsharing.net' + +# Gitea repository owner. +# default GITTALLY_GITEA_OWNER='example-owner' +export GITTALLY_GITEA_OWNER='hostsharing' + +# Gitea repository name. +# default GITTALLY_GITEA_REPO='example-repo' +export GITTALLY_GITEA_REPO='hs.hsadmin.ng' + +# Required HTTPS git username used with the Gitea token. +# default GITTALLY_GITEA_GIT_USERNAME='example-user' +# export GITTALLY_GITEA_GIT_USERNAME='' + +# Required token used for Gitea commit statuses and HTTPS git authentication. +# default GITTALLY_GITEA_TOKEN='' +# export GITTALLY_GITEA_TOKEN='' + +# Gitea commit status context published by GitTally. +# default GITTALLY_GITEA_STATUS_CONTEXT='GitTally' +export GITTALLY_GITEA_STATUS_CONTEXT='GitTally' + +# Fixed status target URL. Leave empty to link to archived build artifacts. +# default GITTALLY_GITEA_STATUS_TARGET_URL='' +export GITTALLY_GITEA_STATUS_TARGET_URL='' diff --git a/legacy/gitTally b/legacy/gitTally new file mode 100755 index 0000000..8755db9 --- /dev/null +++ b/legacy/gitTally @@ -0,0 +1,6152 @@ +#!/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