#!/usr/bin/env bash # Copyright 2001-2026, Paul Johnson (paul@pjcj.net) # This software is free. It is licensed under the same terms as Perl itself. # The latest version of this software should be available from my homepage: # https://pjcj.net if ((BASH_VERSINFO[0] < 5)); then echo "❌ bash version $BASH_VERSION is too old. Please install v5 or higher." exit 1 fi set -eEuo pipefail shopt -s inherit_errexit _p() { __l="$(hostname): $1" shift echo "$__l $script: $*" | tee -a "$LOG_FILE" >&2 } pt() { _p "[TRACE] " "$*"; } pd() { _p "[DEBUG] " "$*"; } pi() { _p "[INFO] " "$*"; } pw() { _p "[WARNING]" "$*"; } pe() { _p "[ERROR] " "$*"; } pf() { _p "[FATAL] " "$*" exit 1 } usage() { cat </dev/null; then # we're inside a docker container so use the remote staging directory results_dir=/remote_staging fi ;; -f | --force) force=1 shift ;; -h | --help) usage ;; -i | --image) docker_image="$2" shift 2 ;; -r | --results_dir) results_dir="$2" shift 2 ;; -t | --trace) set -x shift ;; -v | --verbose) verbose=1 shift ;; *) recipe="$1" shift args=("$@") break ;; esac done } recipe_options() { echo "-d --dryrun" echo "-e --env" echo "-f --force" echo "-h --help" echo "-i --image" echo "-r --results_dir" echo "-t --trace" echo "-v --verbose" declare -F | perl -nE 'say $1 if /recipe_(.+)/' } setup() { script=$(basename "$0") readl=readlink if command -v greadlink >&/dev/null; then readl=greadlink; fi srcdir=$("$readl" -f "$(dirname "$0")") readonly LOG_FILE="/tmp/$script.log" export AUTOMATED_TESTING=1 export NONINTERACTIVE_TESTING=1 export EXTENDED_TESTING=1 PATH="$srcdir:$PATH" docker=docker docker_image=pjcj/cpancover dryrun=0 env=prod force=0 results_dir=~/cover/staging verbose=0 parse_options "$@" } nice_cpus() { perl -Iutils -MDevel::Cover::BuildUtils=nice_cpus -e "print nice_cpus" } recipe_nice-cpus() { nice_cpus } recipe_update-copyright() { local from="${1:-$(date +'%Y' --date='last year')}" local to="${2:-$(date +'%Y')}" pi "Updating copyright from $from to $to" local me="Paul Johnson" local files files=$(git ls-files) # shellcheck disable=SC2086 perl -pi -e "s/Copyright \\d+-\\K$from(, $me)/$to\$1/i" $files # shellcheck disable=SC2086 perl -pi -e "s/Copyright $from\\K(, $me)/-$to\$1/i" $files } get_cpm() { cpanm --notest App::cpm plenv rehash cpm=$(plenv which cpm) } install_dependencies() { get_cpm pi "Installing dependencies with $cpm" $cpm install --workers="$(nice_cpus)" --global \ Sereal Digest::MD5 Template Pod::Coverage::CountParents \ Capture::Tiny Parallel::Iterator Template Class::XSAccessor } install_development_dependencies() { get_cpm $cpm install --workers="$(nice_cpus)" --global \ Dist::Zilla Perl::Critic Perl::Tidy App::perlimports \ Archive::Tar::Wrapper Perl::Critic::PJCJ App::Yath UUID plenv rehash dzil authordeps --missing | xargs "$cpm" install --workers="$(nice_cpus)" --global dzil listdeps --missing | xargs "$cpm" install --workers="$(nice_cpus)" --global } install_test_dependencies() { get_cpm $cpm install --workers="$(nice_cpus)" --global \ DBM::Deep } recipe_install-dependencies() { install_dependencies } recipe_install-development-dependencies() { install_development_dependencies } recipe_install-test-dependencies() { install_test_dependencies } install_perl() { local name="${1:?No name specified}" local version="${2:?No version specified}" yes | plenv uninstall "$name" || true plenv install --as "$name" -j 32 -D usedevel --noman "$version" export PLENV_VERSION="$name" plenv install-cpanm install_dependencies } recipe_install-perl() { local name="${1:?No name specified}" local version="${2:?No version specified}" install_perl "$name" "$version" } recipe_install-cpancover-perl() { local version="${1:?No version specified}" install_perl cpancover "$version" } recipe_install-dc-dev-perl() { local version="${1:?No version specified}" install_perl dc-dev "$version" install_development_dependencies } recipe_all-versions() { ./utils/all_versions "$@" } recipe_build-and-test-all() { perl Makefile.PL make clean perl Makefile.PL make at } run_cpancover() { mkdir -p "$results_dir" local cpancover=cpancover if [[ $(pwd) != /dc ]]; then local root= [[ -d /dc ]] && root=/dc/ PATH="./utils:./bin:$PATH" perl Makefile.PL && make cpancover="perl -Mblib=$root ${root}bin/cpancover --local" fi ((verbose)) && cpancover="$cpancover --verbose" ((force)) && cpancover="$cpancover --force" ((dryrun)) && cpancover="$cpancover --dryrun" local cmd cmd="$cpancover --env $env --results_dir $results_dir" cmd="$cmd --workers $(nice_cpus) $*" ((verbose)) && pi "$cmd" $cmd || true } recipe_cpancover() { run_cpancover "${args[@]:-}" } cpancover_docker_ps() { local name="${docker_image//[^a-zA-Z0-9_.]/-}" $docker ps -a | tail -n +2 | grep "$name-" | grep -vw "$(hostname)" } recipe_cpancover-docker-ps() { cpancover_docker_ps } cpancover_docker_ps_ids() { cpancover_docker_ps | awk '{ print $1 }' || true } recipe_cpancover-docker-kill() { cpancover_docker_ps_ids | xargs -r "$docker" kill } cpancover_docker_rm() { cpancover_docker_ps_ids | xargs -r "$docker" rm -f $docker system prune --force } recipe_cpancover-docker-rm() { cpancover_docker_rm } recipe_cpancover-docker-rm-image() { $docker ps -q --filter ancestor="$docker_image" | xargs -r "$docker" stop $docker ps -aq --filter ancestor="$docker_image" | xargs -r "$docker" rm $docker rmi "$docker_image" } recipe_docker-build() { local build="$srcdir/../docker/BUILD" "$build" -e "$env" "${args[@]:+${args[@]}}" } # docker push authenticates via `docker login` credentials stored in # ~/.docker/config.json. The Docker Hub *web API* (hub.docker.com/v2), # used for listing and deleting tags, has its own auth — POST username + # PAT to /v2/users/login to obtain a JWT. The CLI credentials cannot be # reused for this. # # HUB_USERNAME: your Docker Hub username # HUB_TOKEN: a Personal Access Token created at # https://hub.docker.com/settings/security # (needs Read & Write & Delete permissions) Docker_hub_repo="pjcj/cpancover" # Authenticate with Docker Hub and print a JWT. docker_hub_auth() { if [[ -z "${HUB_USERNAME:-}" || -z "${HUB_TOKEN:-}" ]]; then pf "HUB_USERNAME and HUB_TOKEN environment variables are required" fi local token token=$(curl -sf "https://hub.docker.com/v2/users/login" \ -H "Content-Type: application/json" \ -d "{\"username\":\"$HUB_USERNAME\",\"password\":\"$HUB_TOKEN\"}" | perl -MJSON::PP -e 'print decode_json(do { local $/; })->{token}') if [[ -z "$token" ]]; then pf "Failed to authenticate with Docker Hub" fi echo "$token" } # Fetch versioned tags from Docker Hub into the Hub_tags array (sorted # newest-first). Sets Hub_token for callers that need it afterwards. docker_hub_fetch_tags() { Hub_token=$(docker_hub_auth) Hub_tags=() local base="https://hub.docker.com/v2/repositories" local url="$base/$Docker_hub_repo/tags?page_size=100" while [[ -n "$url" && "$url" != "null" ]]; do local response response=$(curl -sf "$url" -H "Authorization: Bearer $Hub_token") local parsed parsed=$(echo "$response" | perl -MJSON::PP -e ' my $d = decode_json(do { local $/; }); for (@{$d->{results}}) { my $n = $_->{name}; print "$n\n" if $n =~ /^\d{4}-\d{2}-\d{2}-\d{4}-/; } print "---NEXT---\n"; print $d->{next} // "null", "\n"; ') while IFS= read -r t; do [[ "$t" == "---NEXT---" ]] && break [[ -n "$t" ]] && Hub_tags+=("$t") done <<<"$parsed" url=$(tail -1 <<<"$parsed") done # Sort newest-first (the tag format is naturally sortable) mapfile -t Hub_tags < <(printf '%s\n' "${Hub_tags[@]}" | sort -r) } docker_show_tags() { docker_hub_fetch_tags pi "Found ${#Hub_tags[@]} versioned tags for $Docker_hub_repo:" for t in "${Hub_tags[@]}"; do pi " $t" done } recipe_docker-show-tags() { docker_show_tags } docker_prune_tags() { local keep="${1:-20}" docker_show_tags if ((${#Hub_tags[@]} <= keep)); then pi "Nothing to prune (${#Hub_tags[@]} tags <= $keep)" return fi local to_delete=("${Hub_tags[@]:$keep}") pi "Deleting ${#to_delete[@]} old tags (keeping $keep)" for tag in "${to_delete[@]}"; do if ((dryrun)); then pi "[dry run] would delete $Docker_hub_repo:$tag" else if curl -sf -X DELETE \ "https://hub.docker.com/v2/repositories/$Docker_hub_repo/tags/$tag/" \ -H "Authorization: Bearer $Hub_token" >/dev/null; then pi "Deleted $Docker_hub_repo:$tag" else pe "Failed to delete $Docker_hub_repo:$tag" fi fi done pi "Done" } recipe_docker-prune-tags() { docker_prune_tags "${args[@]:-}" } cpancover_latest() { if [[ -n "${CPANCOVER_TEST_MODULES:-}" ]]; then echo "$CPANCOVER_TEST_MODULES" elif [[ -n "${CPANCOVER_TEST_REGEX:-}" ]]; then run_cpancover --latest | grep -E "$CPANCOVER_TEST_REGEX" else run_cpancover --latest fi } recipe_cpancover-latest() { cpancover_latest } recipe_cpancover-build-module() { local module="${1:?No module specified}" local v= ((verbose)) && v=--verbose run_cpancover "$v" --local_build --docker "$docker" --workers 1 "$module" } docker_name() { local name="${1:?No name specified}" name="$docker_image-$name-$(date +%s+%N)" echo "${name//[^a-zA-Z0-9_.]/-}" } recipe_docker-name() { docker_name "${args[@]:-}" } cpancover_controller_command() { local name="${1:?No name specified}" shift local cmd=("$@") mkdir -p "$results_dir" local sock=/var/run/docker.sock local tty_flag="" [[ -t 0 ]] && tty_flag="-t" local env_flags=() while IFS='=' read -r var value; do env_flags+=(--env "$var=$value") done < <(env | grep -E '^(CPANCOVER_|DEVEL_COVER_)' || true) "$docker" run -i ${tty_flag:+"$tty_flag"} \ --mount "type=bind,source=$sock,target=$sock" \ --mount "type=bind,source=$results_dir,target=/remote_staging" \ ${env_flags[@]:+"${env_flags[@]}"} \ --rm=false --memory=1g --name="$(docker_name "$name")" \ "$docker_image" "${cmd[@]}" } recipe_cpancover-controller-shell() { cpancover_controller_command controller_bash "/bin/bash" } recipe_cpancover-docker-shell() { local staging="${1:-$results_dir}" mkdir -p "$staging" $docker run -it \ --mount type=bind,source="$staging",target=/remote_staging \ --rm=false --memory=1g --name="$(docker_name docker)" \ "$docker_image" /bin/bash } # Called from Collection.pm recipe_cpancover-docker-module() { local module="${1:?No module specified}" local name="${2:?No name specified}" local staging="${3:-$results_dir}" local module_timeout="${CPANCOVER_TIMEOUT:-1800}" # half an hour local log_name="$name" name=$(docker_name "$name") mkdir -p "$staging" ((verbose)) && pi "module: $module" local v= ((verbose)) && v=--verbose container=$($docker run -d \ --rm=false --memory=1g --name="$name" \ "$docker_image" \ dc $v cpancover-build-module "$module") ((verbose)) && pi "container is $container" if timeout "$module_timeout" "$docker" wait "$name" >/dev/null 2>&1; then $docker logs "$name" | tee "$staging/$log_name.out" local_staging="$staging/$name" mkdir -p "$local_staging" $docker cp "$name:/root/cover/staging" "$local_staging" if [[ -d $local_staging ]]; then chmod -R 755 "$local_staging" find "$local_staging" -type f -exec chmod 644 {} \; chown -R "$(id -un):$(id -gn)" "$local_staging" cd "$local_staging"/* || exit for f in *; do if [[ -d $f && ! -d "$staging/$f" ]]; then echo "$log_name.out.gz" >"$f/.log_ref" mv "$f" "$staging" fi done rm -r "$local_staging" fi else pi "module timed out after ${module_timeout}s, killing container: $module" $docker logs "$name" | tee "$staging/$log_name.out" 2>/dev/null || true $docker kill "$name" >/dev/null 2>&1 || true fi $docker rm "$name" >/dev/null 2>&1 || true } cpancover_compress_dir() { local dir="$1" do_sidecars="${2:-}" prefix="${3:-}" shift 3 2>/dev/null || shift $# local exclude=("$@") rm -rf "$dir/runs" "$dir/structure" rm -f "$dir"/digests "$dir"/digests.gz "$dir"/cover.[0-9]* \ "$dir"/cover.[0-9]*.gz "$dir"/*.lock "$dir"/*.lock.gz local find_args=("$dir" -maxdepth 1 -type f -not -name '*.gz' -not -name '*.xz' -not -name '*.json' -not -name 'virtual_unzipped' -not -name '.log_ref') local name for name in "${exclude[@]}"; do find_args+=(-not -name "$name") done if find "${find_args[@]}" -print -quit | read -r; then pi "${prefix}compressing" find "${find_args[@]}" -print0 | xargs -0 pigz -f9 fi [[ -n "$do_sidecars" ]] || return 0 local marker="$dir/virtual_unzipped" created=false gzfile basefile for gzfile in "$dir"/*.gz; do [[ -f "$gzfile" ]] || continue [[ -f "$marker" ]] || touch "$marker" basefile="${gzfile%.gz}" if [[ ! -e "$basefile" ]]; then ln "$marker" "$basefile" created=true fi done if [[ "$created" == true ]]; then pi "${prefix}created sidecars" fi } cpancover_compress_dirs() { local only_new="$1" local max_jobs="${CPANCOVER_COMPRESS_JOBS:-$(nice_cpus)}" local jobs=0 dir name prefix rm -f "$results_dir"/about.html.gz "$results_dir"/index.html.gz \ "$results_dir"/collection.css.gz "$results_dir"/cpancover.json.gz cpancover_compress_dir "$results_dir" "" "top-level: " \ about.html index.html collection.css # dist/ contains generated HTML that must be served uncompressed; # delete stale .gz files so Caddy serves the fresh versions. rm -f "$results_dir"/dist/*.gz for dir in "$results_dir"/*/; do [[ -d "$dir" ]] || continue name="$(basename "$dir")" [[ "$name" == __failed__ || "$name" == dist ]] && continue [[ "$only_new" == true && -f "$dir/virtual_unzipped" ]] && continue printf -v prefix "%-40.40s " "$name" cpancover_compress_dir "$dir" sidecars "$prefix" & ((++jobs >= max_jobs)) && { wait -n ((jobs--)) } done wait } cpancover_compress() { pi "compressing $results_dir" cpancover_compress_dirs false } cpancover_compress_new() { pi "compressing new directories in $results_dir" cpancover_compress_dirs true } recipe_cpancover-compress() { cpancover_compress } recipe_cpancover-compress-new() { cpancover_compress_new } cpancover_serve() { local www_dir="$results_dir/../www_${env}" local latest="$www_dir/latest" mkdir -p "$www_dir" if [[ ! -L "$latest" ]]; then ln -s "$results_dir" "$latest" fi local port="${1:-8080}" local caddyfile caddyfile="$(mktemp)" cat >"$caddyfile" <"$tmpfile" if ! caddy validate --adapter caddyfile --config "$tmpfile"; then rm -f "$tmpfile" pf "Caddyfile validation failed" fi echo "$tmpfile" } cpancover_configure_caddy() { local caddyfile="/etc/caddy/Caddyfile" local tmpfile tmpfile="$(cpancover_validated_caddyfile)" trap 'rm -f "$tmpfile"' RETURN if diff -q "$caddyfile" "$tmpfile" >/dev/null 2>&1; then pi "Caddyfile is already up to date" return fi sudo install -m 644 -o root -g root "$tmpfile" "$caddyfile" sudo systemctl reload caddy pi "Caddyfile updated and Caddy reloaded" } recipe_cpancover-configure-caddy() { cpancover_configure_caddy } cpancover_check_caddy() { local caddyfile="/etc/caddy/Caddyfile" local tmpfile tmpfile="$(cpancover_validated_caddyfile)" trap 'rm -f "$tmpfile"' RETURN pi "Generated config validates OK" local differ="diff -u" command -v delta >/dev/null && differ="delta" if diff -q "$caddyfile" "$tmpfile" >/dev/null 2>&1; then pi "Caddyfile is up to date" else pw "Caddyfile differs from generated config:" $differ "$caddyfile" "$tmpfile" fi } recipe_cpancover-check-caddy() { cpancover_check_caddy } recipe_cpancover-uncompress-dir() { subdir="${1:?No subdir specified}" find "$results_dir/$subdir/" -name __failed__ -prune -o \ -type f -name '*.gz' \ -exec gzip -df {} \; } cpancover_compress_old_versions() { keep="${1:-3}" run_cpancover --nobuild --compress_old_versions "$keep" } recipe_cpancover-compress-old-versions() { cpancover_compress_old_versions "${args[@]:-}" } cpancover_generate_html() { pi "Generating HTML at $(date)" cpancover_compress_old_versions run_cpancover --generate_html cpancover_compress_new local json=$results_dir/cpancover.json local tmp=$json-tmp-$$.gz pi "Compressing $json" pigz <"$json" >"$tmp" && mv "$tmp" "$json.gz" pi "Done" } recipe_cpancover-generate-html() { cpancover_generate_html } cpancover_build() { pi "Starting cpancover build at $(date) on $(nice_cpus) cpus" cpancover_docker_rm run_cpancover --build cpancover_generate_html pi "Finished cpancover build at $(date)" } cpancover_run_once() { cpancover_latest | cpancover_build } recipe_cpancover-build-stdin() { cpancover_build } cpancover_run_loop() { while true; do cpancover_run_once sleep 600 # 10 minutes done } recipe_cpancover-run-once() { cpancover_run_once } recipe_cpancover-run-loop() { cpancover_run_loop } recipe_cpancover-controller-run() { local o=(--env "$env") ((verbose)) && o+=("--verbose") cpancover_controller_command controller dc "${o[@]}" cpancover-run-loop } recipe_cpancover-controller-run-once() { local o=(--env "$env") ((verbose)) && o+=("--verbose") cpancover_controller_command controller dc "${o[@]}" cpancover-run-once } # Default test module - small, stable, predictable cpancover_default_test_module="P/PJ/PJCJ/Perl-Critic-PJCJ-v0.2.4.tar.gz" recipe_cpancover-test() { : "${CPANCOVER_TEST_MODULES:=$cpancover_default_test_module}" export CPANCOVER_TEST_MODULES pi "Testing with modules: $CPANCOVER_TEST_MODULES" cpancover_run_once } recipe_cpancover-controller-test() { : "${CPANCOVER_TEST_MODULES:=$cpancover_default_test_module}" pi "Testing with modules: $CPANCOVER_TEST_MODULES" local o=(--env "$env") ((verbose)) && o+=("--verbose") echo "$CPANCOVER_TEST_MODULES" | cpancover_controller_command controller dc "${o[@]}" cpancover-build-stdin } recipe_cpancover-start-queue() { COVER_DEBUG=1 perl bin/queue minion worker -j 4 } recipe_cpancover-start-minion() { COVER_DEBUG=1 perl bin/queue daemon -l http://\*:30000 -m production } recipe_cpancover-add() { module="${1:?No module specified}" COVER_DEBUG=1 perl bin/queue add "$module" } generate_gcov_fixtures() { if ! command -v gcc >/dev/null; then pf "gcc is required to generate gcov fixtures" fi if ! command -v gcov >/dev/null; then pf "gcov is required to generate gcov fixtures" fi local fixtures_dir="t/fixtures/gcov2perl" pi "Generating gcov fixtures in $fixtures_dir" pushd "$fixtures_dir" >/dev/null for fixture in *.c.fixture; do [[ -f "$fixture" ]] || continue local src="${fixture%.fixture}" local base="${src%.c}" pi "Processing $fixture" # gcc needs a .c file cp "$fixture" "$src" # Compile with gcov instrumentation gcc -fprofile-arcs -ftest-coverage -o "$base" "$src" # Run to generate .gcda coverage data ./"$base" || true # Allow non-zero exit for branch coverage # Generate .gcov file # On macOS/clang, gcno/gcda files are named -.gc* # so we call gcov with the object prefix, not the source file if [[ -f "$base-$base.gcno" ]]; then gcov "$base-$base" >/dev/null else gcov "$src" >/dev/null fi # Rename to .fixture so cover's *.gcov scan won't find it mv "$src.gcov" "$src.gcov.fixture" # Clean up intermediates, keep only .c.fixture and .gcov.fixture rm -f "$src" "$base" "$base.gcno" "$base.gcda" \ "$base-$base.gcno" "$base-$base.gcda" pi "Generated $src.gcov.fixture" done popd >/dev/null } recipe_generate-gcov-fixtures() { generate_gcov_fixtures } run_recipe() { recipe="recipe_$recipe" shift if declare -F "$recipe" >/dev/null 2>&1; then "$recipe" "${args[@]:-}" else pf "Unknown recipe: $recipe" fi } main() { setup "$@" ((verbose)) && pi "Running $recipe ${args[*]:-}" [[ ${recipe:-} == "" ]] && pf "Missing recipe" run_recipe "${args[@]:-}" } if [[ ${BASH_SOURCE[0]} == "$0" ]]; then trap cleanup EXIT INT main "$@" fi