Files
EpicNext-Cms/scripts/ci-deploy.sh
T
openhands 8ec3df541e
Gitea Actions Runner Test / test-job (push) Successful in 2s
CI / check (push) Successful in 30s
CI / tests-integration (push) Successful in 1m43s
CI / tests-unit (push) Successful in 1m47s
CI / tests-ui (push) Successful in 2m33s
CI / preflight (push) Skipped
CI / deploy (push) Failing after 1m30s
fix(deploy): name the container blocking a port and silence phantom cleanup
Two follow-ups from the blocked deploy.

The rollback path called `docker logs` and `docker rm -f` on the candidate
unconditionally. When the port check refuses to start it, the container was
never created, so both printed "No such container: epicnext-cms-app" — noise
that looked like a second, unrelated failure and buried the real message.
Both calls are now guarded by `docker inspect`.

assert_port_free() now reports which container holds the port and flags it when
it is not a blue/green slot this script manages. The previous output listed
every container and said only "port already in use", which is a dead end: on
this host the holder is `epicnext-cms` (a `docker compose up` replica on port
3002), while the deploy manages slot A as `epicnext-cms-app`. The names differ
because docker-compose.yml pins `container_name: epicnext-cms` for the `cms`
service; slot B happens to match, which is why 3003 deploys fine and 3002 never
can. The message now names the squatter, explains that live traffic is
unaffected, and gives the next action.

Deploy simulation: 26 passed.
2026-10-03 18:37:12 +02:00

508 lines
21 KiB
Bash

#!/usr/bin/env bash
# One lock covers build, migrations, cutover, health checks and smoke tests.
set -Eeuo pipefail
deploy_dir="${CMS_DEPLOY_DIR:-/var/www/atom-nexst}"
branch="${DEPLOY_BRANCH:-main}"
case "$branch" in main|master) ;; *) echo "Unsupported deployment branch" >&2; exit 1 ;; esac
exec 9>"$deploy_dir/.deploy.lock"
flock -w 1800 9
sha="$(git rev-parse HEAD)"
[[ "$sha" =~ ^[0-9a-f]{40}$ ]] || { echo "Invalid commit" >&2; exit 1; }
image="epicnext-cms:$sha"
previous_name=""
previous_image=""
secondary_name=""
secondary_backup="epicnext-cms-rollback-secondary"
secondary_backup_created=0
backup_name="epicnext-cms-rollback"
cutover_started=0
candidate_attempted=0
backup_created=0
# ── Blue/green ──
# De app draait met `--net=host`, dus elke replica neemt een eigen host-poort in
# plaats van een gedeelde docker-poort. Daardoor zijn er twee vaste slots en kan
# een release naast de live release opstarten. De nginx-upstream wijst pas naar
# de nieuwe release nadat die gezond is én de browsersmoke-test heeft gewonnen.
slot_a_port=3002
slot_b_port=3003
slot_a_container="epicnext-cms-app"
slot_b_container="epicnext-cms-green"
# Overridable zodat de simulatietests een pad kunnen opgeven dat niet bestaat en
# zo het in-place pad kunnen testen, en zodat een host met een andere
# nginx-indeling niet stilvalt op een release.
upstream_file="${CMS_UPSTREAM_FILE:-/etc/nginx/snippets/cms_upstream_servers.conf}"
nginx_site="${CMS_NGINX_SITE:-/etc/nginx/sites-enabled/cms.conf}"
active_port=""
old_port=""
new_port=""
old_container=""
new_container=""
blue_green=0
# Resource limits voor de container. Zonder deze limieten kan één geheugenlek de
# hele host vullen, met MariaDB, nginx en Traefik als slachtoffer. 2 CPU laat
# achtergrondwerk toe zonder de hele kernel aan één release te geven.
mem_limit="4g"
mem_swap_limit="5g"
cpu_limit="2"
pids_limit="512"
is_current() {
local head
head="$(git ls-remote --exit-code origin "refs/heads/$branch")" || return 2
head="${head%%[[:space:]]*}"
if [ "$head" != "$sha" ]; then
echo "Skipping superseded commit $sha (branch now at $head)"
return 1
fi
}
check_current() {
local status=0
is_current || status=$?
case "$status" in 0) ;; 1) exit 0 ;; *) echo "Cannot verify remote branch" >&2; exit 1 ;; esac
}
healthy() {
local port="${1:-$slot_a_port}"
local attempt
for attempt in $(seq 1 30); do
if curl -sf --max-time 5 "http://127.0.0.1:$port/api/health" | grep -q '"database":true'; then return 0; fi
sleep 3
done
return 1
}
# Zelfde check als `healthy`, maar zonder retries. Voor het bepalen van de
# actieve poort willen we geen 90 seconden per slot wachten: daar gaat het om
# een al draaiend proces dat nu of nooit antwoordt.
answers_health() {
curl -sf --max-time 5 "http://127.0.0.1:$1/api/health" | grep -q '"database":true'
}
# Staat er een blue/green-upstream? Zonder die bestanden blijft dit script op de
# oude, in-place cutover vallen, zodat een host met een andere nginx-indeling
# niet stilvalt op een upgrade.
detect_blue_green() {
[ -r "$upstream_file" ] || return 1
grep -qs 'cms_app' "$nginx_site" || return 1
return 0
}
# Welke poort is op dit moment ÉCHT live?
#
# Volgorde van vertrouwen:
# 1. Het nginx-upstream-bestand. Dat is de enige bron die aangeeft wáár het
# publieke verkeer daadwerkelijk binnenkomt; alles daaronder is gevolg.
# 2. Een gezond slot dat overeenkomt met die aanwijzing.
# 3. Precies één gezond slot (een verse host met geen upstream-bestand).
#
# De eerdere versie telde gezonde slots en gebruikte de fallback pas als er 0 of
# 2+ waren. Op een host waar beide slots tegelijk gezond zijn — bijvoorbeeld
# doordat een losse `docker compose up` een extra replica heeft achtergelaten —
# gaf dat een willekeurige keuze, en dan kon de kandidaat op een bezette poort
# starten (EADDRINUSE) terwijl de health-check de reeds draaiende container op
# die poort beantwoordde. De release-vergelijking faalde dan 30 keer op een
# container die toevallig een andere release draaide.
read_active_port() {
local port="" pointed=""
if [ -r "$upstream_file" ]; then
port="$(grep -oE '127\.0\.0\.1:(3002|3003)' "$upstream_file" 2>/dev/null | head -1 | cut -d: -f2 || true)"
fi
if [ -n "$port" ] && answers_health "$port"; then
printf '%s' "$port"
return 0
fi
# Het upstream-bestand wijst naar een slot dat niet antwoordt. Kies dan het
# enige andere gezonde slot, anders is er niets om op te bouwen.
for candidate in "$slot_a_port" "$slot_b_port"; do
[ "$candidate" = "$port" ] && continue
if answers_health "$candidate"; then
echo "nginx points at ${port:-unknown}, which is unhealthy; ${candidate} answers instead" >&2
printf '%s' "$candidate"
return 0
fi
done
# Geen enkel slot antwoordt. Vertrouw dan op het bestand, zodat een
# rollback-poging toch het vorige slot kan starten.
if [ -n "$port" ]; then
printf '%s' "$port"
return 0
fi
printf '%s' "$slot_a_port"
}
# Poort-bezetting controleren vóór het starten van de kandidaat.
#
# Zonder deze check zorgt `docker run` er stilzwijgend voor dat de kandidaat
# dood gaat op EADDRINUSE, terwijl de health-check ondertussen de reeds draaiende
# container op diezelfde poort beantwoordt. Dat levert een misleidende
# "expected release never became healthy" op in plaats van de echte oorzaak.
# Elke listener wordt hierboven concreet genoemd, inclusief de container die
# hem vasthoudt.
assert_port_free() {
local port="$1" name="$2"
local holders=""
# `type`, niet `command -v`: de deploy-simulatietests leveren `ss` als
# shell-functie via BASH_ENV, en `command -v` herkent die wel op Bash maar de
# functie is niet geëxporteerd naar de subshell van start_candidate. Met `type`
# blijft de stub ook daar zichtbaar, zodat de test geen echte hostpoorten
# hoeft te zien.
if type ss >/dev/null 2>&1; then
# `ss` drukt altijd een kolomkop af, ook als er geen listener is. Filter op
# LISTEN, anders zou elke vrije poort als bezet gemeld worden.
holders="$(ss -ltnp "sport = :$port" 2>/dev/null | grep -F 'LISTEN' || true)"
fi
[ -z "$holders" ] && return 0
echo "Port $port is already in use, cannot start candidate $name" >&2
printf '%s\n' "$holders" >&2
echo "--- containers currently running ---" >&2
docker ps --format '{{.Names}}\t{{.Image}}\t{{.Status}}' >&2 || true
# Blame de container die de poort vasthoudt. De blauwe/groene release
# beheert zijn slots zelf, dus een container met een andere naam die hier
# toevallig op dezelfde poort draait (vaak gestart met `docker compose up`)
# is per definitie een losse replica. Zonder deze aanwijzing blijft een
# mislukte deploy een dode hoek in: het log meldde alleen een bezette poort,
# niet wie erachter zat.
echo "--- who holds port $port ---" >&2
docker ps --filter "publish=$port" --format '{{.Names}}\t{{.Image}}\t{{.Status}}' >&2 || true
local squatters=""
while IFS= read -r squatter; do
[ -n "$squatter" ] || continue
case "$squatter" in
"$name"|epicnext-cms-green|epicnext-cms-app) continue ;;
esac
echo " $squatter is not a managed blue/green slot for port $port." >&2
done < <(docker ps --format '{{.Names}}' 2>/dev/null || true)
echo "" >&2
echo "Stop or remove it, then re-run the deploy. Live traffic is unaffected:" >&2
echo "the nginx upstream keeps serving the other slot until cutover." >&2
return 1
}
# Zet de nginx-upstream op de nieuwe poort en herlaadt graceful.
#
# De nieuwe inhoud wordt eerst echt weggeschreven en dán getest: `nginx -t` leest
# het include-bestand van schijf, dus alleen achteraf testen zou de oude, werkende
# configuratie blijven valideren. Faalt de test, dan gaat het origineel onmiddellijk
# terug en raakt de live release niets.
switch_upstream() {
local port="$1"
local backup="${upstream_file}.deploy.bak"
if ! cp "$upstream_file" "$backup" 2>/dev/null; then
echo "Cannot back up $upstream_file; refusing to cut over" >&2
return 1
fi
{
echo "# Geschreven door scripts/ci-deploy.sh op $(date -u +%FT%TZ) voor release $sha."
echo "# Niet met de hand bewerken: de volgende deploy overschrijft dit bestand."
echo "server 127.0.0.1:${port} max_fails=2 fail_timeout=10s;"
} >"$upstream_file"
if ! nginx -t >/dev/null 2>&1; then
echo "nginx rejected the new upstream; restoring the previous one" >&2
mv "$backup" "$upstream_file"
return 1
fi
if ! nginx -s reload; then
echo "nginx reload failed; restoring the previous upstream" >&2
mv "$backup" "$upstream_file"
nginx -s reload || true
return 1
fi
rm -f "$backup"
# Even de tijd voor de graceful reload om de nieuwe worker te laten starten.
sleep 1
return 0
}
# Start de kandidaat op een eigen host-poort.
#
# De resource limits staan hier bewust bij de `docker run` en niet alleen in
# docker-compose.yml: een release wordt hier gestart en gebruikt compose helemaal
# niet, dus limieten die alleen in de compose stonden zouden in productie nooit
# gelden.
start_candidate() {
local port="$1" name="$2"
assert_port_free "$port" "$name"
(
set -a
# shellcheck disable=SC1091
. "$deploy_dir/.env"
set +a
ENV_ARGS=()
while IFS='=' read -r key _; do
case "$key" in ''|'#'*|*[!A-Za-z0-9_]* ) continue ;; esac
ENV_ARGS+=(-e "$key")
done < "$deploy_dir/.env"
docker run -d --name "$name" --restart always --net=host \
--memory="$mem_limit" --memory-swap="$mem_swap_limit" \
--cpus="$cpu_limit" --pids-limit="$pids_limit" \
"${ENV_ARGS[@]}" -e "PORT=$port" -e HOSTNAME=0.0.0.0 \
-v "$deploy_dir/public/nitro-assets:/app/public/nitro-assets" \
-v "$deploy_dir/public/swf:/app/public/swf" \
-v "$deploy_dir/storage:/app/storage" \
-v /var/www/Gamedata:/var/www/Gamedata \
"$image"
)
}
finish() {
local status=$?
trap - EXIT
if [ "$status" -ne 0 ] && [ "$blue_green" -eq 1 ]; then
# Er zijn twee soorten falen, en het verschil bepaalt hoeveel werk terug moet.
if [ "$cutover_started" -eq 0 ]; then
# De live release draait nog ongestoord; alleen de kandidaat opruimen.
echo "Deployment failed before cutover; the live release was never stopped" >&2
if [ "$candidate_attempted" -eq 1 ] && [ -n "$new_container" ] && docker inspect "$new_container" >/dev/null 2>&1; then
docker logs "$new_container" --tail 50 >&2 || true
docker rm -f "$new_container" || true
fi
else
# nginx wijst nu naar de kandidaat. Eerst het verkeer terug, dan pas de
# kandidaat weghalen, anders zou de site 502-en terwijl we terugdraaien.
echo "Deployment failed after cutover; rolling back to port $old_port" >&2
if [ -n "$new_container" ] && docker inspect "$new_container" >/dev/null 2>&1; then docker logs "$new_container" --tail 50 >&2 || true; fi
if [ -n "$old_port" ]; then switch_upstream "$old_port" || true; fi
if [ -n "$new_container" ] && docker inspect "$new_container" >/dev/null 2>&1; then docker rm -f "$new_container" || true; fi
if [ -n "$old_container" ] && docker start "$old_container" >/dev/null 2>&1; then
if healthy "$old_port"; then
echo "Rollback verified on port $old_port"
else
echo "ERROR: previous container did not return to a healthy state" >&2
fi
else
echo "ERROR: no previous container to roll back to" >&2
fi
fi
elif [ "$status" -ne 0 ] && [ "$cutover_started" -eq 1 ]; then
# In-place pad (geen blue/green-upstream): het oude scriptgedrag.
echo "Deployment failed; restoring previous container" >&2
docker logs epicnext-cms-app --tail 50 >&2 || true
if command -v ss >/dev/null 2>&1; then ss -ltnp 'sport = :3002' >&2 || true; fi
if [ "$candidate_attempted" -eq 1 ]; then docker rm -f epicnext-cms-app || true; fi
if [ "$backup_created" -eq 1 ]; then docker rename "$backup_name" "$previous_name" || true; fi
if [ "$secondary_backup_created" -eq 1 ]; then
docker rename "$secondary_backup" "$secondary_name" || true
fi
if [ -n "$previous_name" ]; then
if docker start "$previous_name" && healthy; then
echo "Rollback verified: $previous_image"
else
echo "ERROR: previous container could not be restored to healthy state" >&2
fi
else
echo "No previous container exists; rollback is unavailable" >&2
fi
if [ "$secondary_backup_created" -eq 1 ]; then docker start "$secondary_name" || true; fi
fi
exit "$status"
}
trap finish EXIT
trap 'exit 130' INT
trap 'exit 143' TERM
check_current
# Read-only ownership evidence before any build or container cutover.
if command -v ss >/dev/null 2>&1; then
listeners="$(ss -ltnp 'sport = :3002' 2>/dev/null || true)"
printf '%s\n' "$listeners"
while read -r listener_pid; do
[ -n "$listener_pid" ] || continue
printf 'Port owner PID=%s cwd=' "$listener_pid"
readlink "/proc/$listener_pid/cwd" || true
cat "/proc/$listener_pid/cgroup" 2>/dev/null || true
ps -o pid=,ppid=,user=,comm= -p "$listener_pid" || true
done < <(printf '%s' "$listeners" | grep -o 'pid=[0-9]*' | cut -d= -f2 | sort -u)
fi
for managed_name in epicnext-cms epicnext-cms-app; do
docker inspect --format '{{.Name}} running={{.State.Running}} pid={{.State.Pid}} image={{.Image}}' "$managed_name" 2>/dev/null || true
done
[ "$deploy_dir/.env" -ef .env ] || cp "$deploy_dir/.env" .env
# De migraties draaien op de host tegen DATABASE_URL, niet in de container. Die
# waarde staat alleen in $deploy_dir/.env, dus controleer hem hier: anders
# bouwen we eerst een image en doorlopen we de browsergate voordat `db:migrate`
# op een lege DATABASE_URL stukloopt. Dat is een halve release voor niets.
if ! grep -qs '^DATABASE_URL=' .env; then
echo "Error: DATABASE_URL ontbreekt in $deploy_dir/.env" >&2
echo " Zet daar een DATABASE_URL (mysql://user:pass@host:3306/db) en draai opnieuw." >&2
echo " De live release is niet aangeraakt; deze release is niet uitgerold." >&2
exit 1
fi
pnpm install --frozen-lockfile
pnpm exec playwright install chromium
echo "Building $image"
DOCKER_BUILDKIT=1 docker build --network=host --progress=plain --cache-from epicnext-cms:latest \
--build-arg NEXT_DEPLOYMENT_ID="$sha" -t "$image" .
check_current
# Read reports from the already-built image; do not start an extra application.
report_container=""
if report_container="$(docker create --entrypoint /bin/true "$image")" && [ -n "$report_container" ]; then
mkdir -p build-reports
if docker cp "$report_container:/app/build-reports/." build-reports && [ -s build-reports/report.md ]; then
cat build-reports/report.md
if [ -n "${GITHUB_STEP_SUMMARY:-}" ]; then
cat build-reports/report.md >> "$GITHUB_STEP_SUMMARY" || echo "Warning: could not append performance summary" >&2
fi
else
echo "Warning: build performance report unavailable" >&2
fi
docker rm "$report_container" >/dev/null
else
echo "Warning: could not extract build performance report" >&2
fi
# Exercise the candidate with disposable services before any live migration or cutover.
NEWS_E2E_IMAGE="$image" NEWS_E2E_RELEASE="$sha" node --import tsx e2e/news-real/run.ts
check_current
pnpm db:migrate
check_current
# ── Blue/green: bepaal wie live is en waar de kandidaat mag starten ──
if detect_blue_green; then
blue_green=1
active_port="$(read_active_port)"
if [ "$active_port" = "$slot_b_port" ]; then
old_port=$slot_b_port; new_port=$slot_a_port
old_container=$slot_b_container; new_container=$slot_a_container
else
old_port=$slot_a_port; new_port=$slot_b_port
old_container=$slot_a_container; new_container=$slot_b_container
fi
echo "Live release keeps serving port $old_port; candidate starts on port $new_port"
# Rollback-evidence vastleggen voordat er iets wordt veranderd.
if docker inspect "$old_container" >/dev/null 2>&1; then
previous_name="$old_container"
previous_image="$(docker inspect --format '{{.Image}}' "$old_container")"
docker tag "$previous_image" epicnext-cms:previous
else
# Eerste release op een verse blue/green-opstelling: er is nog niets live.
previous_name=""
old_container=""
fi
else
blue_green=0
# Prefer the active CI container, or the active legacy compose container.
for name in epicnext-cms epicnext-cms-app; do
if [ "$(docker inspect --format '{{.State.Running}}' "$name" 2>/dev/null || true)" = true ]; then
if [ -z "$previous_name" ]; then previous_name="$name"; else secondary_name="$name"; fi
fi
done
if [ -z "$previous_name" ]; then
for name in epicnext-cms-app epicnext-cms; do
if docker inspect "$name" >/dev/null 2>&1; then previous_name="$name"; break; fi
done
fi
if [ -n "$previous_name" ]; then
previous_image="$(docker inspect --format '{{.Image}}' "$previous_name")"
docker tag "$previous_image" epicnext-cms:previous
fi
# Remove a stopped leftover CI container when the compose container is active.
if [ "$previous_name" != epicnext-cms-app ] && [ "$secondary_name" != epicnext-cms-app ] && docker inspect epicnext-cms-app >/dev/null 2>&1; then
docker rm epicnext-cms-app
fi
fi
if docker inspect "$backup_name" >/dev/null 2>&1; then
echo "Unresolved rollback container exists; refusing to overwrite it" >&2
exit 1
fi
# The avatar/badge disk cache lives on the host bind and is written by uid 33
# inside the container. Root-owned directories make every cache write fail
# silently, which turns each avatar into a fresh live render.
for cache_dir in avatars badges; do
if ! install -d -o 33 -g 33 -m 0750 "$deploy_dir/storage/imaging/$cache_dir" 2>/dev/null; then
mkdir -p "$deploy_dir/storage/imaging/$cache_dir" 2>/dev/null || true
fi
done
chown -R 33:33 "$deploy_dir/storage/imaging" 2>/dev/null || true
if [ "$blue_green" -eq 1 ]; then
# 1. Maak de doel-poort vrij. Alles wat daar draait is per definitie niet live,
# want nginx wijst nog naar old_port. Een restje van een mislukte eerdere
# deploy mag de nieuwe release niet blokkeren.
if docker inspect "$new_container" >/dev/null 2>&1; then
docker rm -f "$new_container"
fi
# 2. Start de kandidaat ernaast. De live release draait ononderbroken door.
candidate_attempted=1
start_candidate "$new_port" "$new_container"
# 3. Gezond? Release-hash klopt? Browsersmoke-test? Pas dan hoeft het oude
# release het veld te ruimen — anders zou een mislukte e2e-test pas ná de
# cutover de productie breken in plaats van ervoor.
healthy "$new_port"
node scripts/verify-deployed-release.mjs "http://127.0.0.1:$new_port/api/health" "$sha"
PLAYWRIGHT_BASE_URL="http://127.0.0.1:$new_port" pnpm test:e2e
# 4. Het enige onomkeerbare moment: vanaf hier wijst nginx naar de kandidaat.
cutover_started=1
switch_upstream "$new_port"
echo "Cut over to port $new_port; retiring port $old_port"
# 5. Nu mag de oude release weg. Pas ná de swap, zodat er nooit een moment is
# waarop er geen enkele container draait.
if [ -n "$old_container" ] && docker inspect "$old_container" >/dev/null 2>&1; then
docker stop "$old_container"
docker rename "$old_container" "$backup_name"
backup_created=1
fi
verified_image="$(docker inspect --format '{{.Image}}' "$new_container")"
else
# In-place pad, alleen voor hosts zonder blue/green-upstream.
cutover_started=1
# Both legacy Compose and CI containers can exist after earlier failed updates.
# Preserve each before releasing the shared host port; never kill an arbitrary PID.
if [ -n "$secondary_name" ]; then
docker stop "$secondary_name"
docker rename "$secondary_name" "$secondary_backup"
secondary_backup_created=1
fi
if [ -n "$previous_name" ]; then
docker stop "$previous_name"
docker rename "$previous_name" "$backup_name"
backup_created=1
fi
candidate_attempted=1
start_candidate 3002 epicnext-cms-app
healthy 3002
node scripts/verify-deployed-release.mjs http://127.0.0.1:3002/api/health "$sha"
PLAYWRIGHT_BASE_URL=http://127.0.0.1:3002 pnpm test:e2e
verified_image="$(docker inspect --format '{{.Image}}' epicnext-cms-app)"
fi
docker tag "$verified_image" "epicnext-cms:verified-$sha"
docker tag "$verified_image" epicnext-cms:latest
cutover_started=0
if [ "$backup_created" -eq 1 ]; then docker rm "$backup_name" || true; fi
if [ "$secondary_backup_created" -eq 1 ]; then docker rm "$secondary_backup" || true; fi
echo "Deployment verified: $sha"
# Een deploy kan de game client, furnidata (gamedata), camera en public API data
# verversen. Laat de Cloudflare edge-cache van die tags los (best-effort: alleen
# wanneer er een echte token + zone-id geconfigureerd is; no-op anders).
if [ -x "$deploy_dir/scripts/cf-purge.sh" ]; then
bash "$deploy_dir/scripts/cf-purge.sh" cms-public cms-gamedata cms-client cms-camera || true
fi
# Retain the current and previous releases; do not remove arbitrary named tags.
while IFS= read -r tag; do
if [[ "$tag" =~ ^epicnext-cms:(verified-)?[0-9a-f]{40}$ ]] && [ "$tag" != "$image" ] && [ "$tag" != "epicnext-cms:verified-$sha" ]; then
tagged_image="$(docker image inspect --format '{{.Id}}' "$tag" 2>/dev/null || true)"
if [ -n "$tagged_image" ] && [ "$tagged_image" != "$previous_image" ]; then docker image rm "$tag" || true; fi
fi
done < <(docker image ls --format '{{.Repository}}:{{.Tag}}' epicnext-cms)
# Reclaim build cache, unreferenced images and long-stopped containers. Never
# volumes; retention boundaries are enforced inside docker-prune.sh.
bash "$deploy_dir/scripts/docker-prune.sh" || true