From f1c0567b66e9a3207b02f7cd7d1fcc5efa85a8e0 Mon Sep 17 00:00:00 2001 From: owine Date: Fri, 9 Oct 2026 23:49:13 -0500 Subject: [PATCH 1/3] fix(deploy): resolve compose project from container labels for no-env calls Follow-up to #99 review. ps/logs/down that run without `op run` now go through a compose_p helper (installed per job into $RUNNER_TEMP) that reads the project name from the containers' com.docker.compose.project.working_dir label, falling back to the stack name. `-p "$stack"` silently missed when Compose normalises the dir name (dots, uppercase, leading _/-) or a compose file sets a top-level name:, leaving teardown/rollback a no-op (exit 0) and orphaning containers. Also: teardowns no longer require the compose file to exist (down targets the project label; missing file is now a notice), the health check validates stack names like the other loops, redundant export -f removed, and ensure-shared-networks documents that label-based down removes a project-labelled network even after it is marked external. --- .github/workflows/deploy.yml | 160 +++++++++++++++---- CLAUDE.md | 1 + scripts/deployment/ensure-shared-networks.sh | 4 + 3 files changed, 136 insertions(+), 29 deletions(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index e7f1c97..519128f 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -342,6 +342,36 @@ jobs: echo "skipped=false" >> "$GITHUB_OUTPUT" fi + # Shared by every compose ps/logs/down below that runs without `op run`. + # Keep in sync with the copies in the deploy, health-check and rollback jobs. + - name: Install compose project helper + if: steps.skip-gate.outputs.skipped == 'false' + run: | + set -euo pipefail + cat > "$RUNNER_TEMP/compose-project.sh" <<'SCRIPT' + # usage: compose_project -> the stack's Compose project name + # Read from the containers' working_dir label, so a top-level `name:` or + # a dir name Compose normalises (dots, uppercase, leading _/-) still + # resolves. Falls back to the stack name when it has no containers (then + # there is nothing to act on). awk, not head: no SIGPIPE under pipefail. + compose_project() { + local p + p=$(docker ps -a \ + --filter "label=com.docker.compose.project.working_dir=${LIVE_REPO_PATH%/}/$1" \ + --format '{{.Label "com.docker.compose.project"}}' 2>/dev/null \ + | sort -u | awk 'NR == 1') || p="" + printf '%s\n' "${p:-$1}" + } + # usage: compose_p + # Runs compose against the project by name from / (no compose file there), + # so the stack's compose file is never parsed: these calls run without + # `op run`, and a file whose syntax needs ${VARS} would fail to parse. + compose_p() { + local stack="$1"; shift + (cd / && docker compose -p "$(compose_project "$stack")" "$@") + } + SCRIPT + - name: Teardown removed stacks (uses pre-reset compose files) if: | steps.skip-gate.outputs.skipped == 'false' @@ -359,6 +389,8 @@ jobs: # `name:`, dir names already in compose's [a-z0-9_-] form). run: | set -euo pipefail + # shellcheck source=/dev/null + source "$RUNNER_TEMP/compose-project.sh" for stack in $(echo "$REMOVED_STACKS" | jq -r '.[]'); do [[ "$stack" =~ ^[a-zA-Z0-9._-]+$ ]] || { echo "::error::invalid stack name: $stack"; exit 1; } compose_file="" @@ -368,12 +400,11 @@ jobs: break fi done - if [[ -n "$compose_file" ]]; then - echo "๐Ÿ›‘ Stopping $stack" - (cd / && docker compose -p "$stack" down) || echo "::warning::down failed for $stack" - else - echo "::warning::compose file missing for removed stack $stack" - fi + # down targets the project label, so it no longer needs the file. + [[ -n "$compose_file" ]] \ + || echo "::notice::compose file missing for removed stack $stack; tearing down by project label" + echo "๐Ÿ›‘ Stopping $stack" + compose_p "$stack" down || echo "::warning::down failed for $stack" done # actions/checkout authenticates via GITHUB_TOKEN; the deploy user has no @@ -543,6 +574,8 @@ jobs: set -euo pipefail # shellcheck source=/dev/null source "$RUNNER_TEMP/compose-pull.sh" + # shellcheck source=/dev/null + source "$RUNNER_TEMP/compose-project.sh" cd "$DOCKGE_PATH" compose_pull_retry "$LIVE_REPO_PATH/compose.env" timeout "$SERVICE_STARTUP_TIMEOUT" op run --no-masking --env-file="$LIVE_REPO_PATH/compose.env" -- \ @@ -562,6 +595,8 @@ jobs: set +e # shellcheck source=/dev/null source "$RUNNER_TEMP/compose-pull.sh" + # shellcheck source=/dev/null + source "$RUNNER_TEMP/compose-project.sh" # shellcheck disable=SC2317 # invoked from subshells below dump_failed_stack() { @@ -569,7 +604,7 @@ jobs: local lines="${FAILED_LOG_LINES:-50}" # By project name from / (no compose file there): never parses the # stack's compose file, which can need ${VARS} we don't load here. - dc() { (cd / && docker compose -p "$stack" "$@"); } + dc() { compose_p "$stack" "$@"; } echo "::group::๐Ÿ” Diagnostics: $stack" echo "โ”€โ”€ docker compose ps -a โ”€โ”€" dc ps -a 2>&1 || true @@ -590,7 +625,6 @@ jobs: fi echo "::endgroup::" } - export -f dump_failed_stack failed=() succeeded=0 @@ -654,10 +688,12 @@ jobs: FAILED_EXISTING_STACKS: ${{ steps.deploy-existing.outputs.failed_stacks }} run: | set +e + # shellcheck source=/dev/null + source "$RUNNER_TEMP/compose-project.sh" for stack in $(echo "$FAILED_EXISTING_STACKS" | jq -r '.[]'); do [[ "$stack" =~ ^[a-zA-Z0-9._-]+$ ]] || { echo "::warning::skipping invalid stack: $stack"; continue; } echo "๐Ÿงน Tearing down failed existing stack $stack to clear recreate orphans" - (cd / && docker compose -p "$stack" down) || true + compose_p "$stack" down || true done - name: Deploy new stacks @@ -673,6 +709,8 @@ jobs: set +e # shellcheck source=/dev/null source "$RUNNER_TEMP/compose-pull.sh" + # shellcheck source=/dev/null + source "$RUNNER_TEMP/compose-project.sh" # shellcheck disable=SC2317 dump_failed_stack() { @@ -680,7 +718,7 @@ jobs: local lines="${FAILED_LOG_LINES:-50}" # By project name from / (no compose file there): never parses the # stack's compose file, which can need ${VARS} we don't load here. - dc() { (cd / && docker compose -p "$stack" "$@"); } + dc() { compose_p "$stack" "$@"; } echo "::group::๐Ÿ” Diagnostics: $stack" echo "โ”€โ”€ docker compose ps -a โ”€โ”€" dc ps -a 2>&1 || true @@ -701,7 +739,6 @@ jobs: fi echo "::endgroup::" } - export -f dump_failed_stack failed=() succeeded=0 @@ -756,9 +793,11 @@ jobs: && steps.deploy-new.outcome == 'failure' run: | set +e + # shellcheck source=/dev/null + source "$RUNNER_TEMP/compose-project.sh" for stack in $(echo "$NEW_STACKS" | jq -r '.[]'); do [[ "$stack" =~ ^[a-zA-Z0-9._-]+$ ]] || { echo "::warning::skipping invalid stack: $stack"; continue; } - (cd / && docker compose -p "$stack" down) || true + compose_p "$stack" down || true done - name: Compute deploy summary outputs @@ -802,10 +841,41 @@ jobs: FAILED_LOG_LINES: ${{ inputs.failed-container-log-lines }} OP_SERVICE_ACCOUNT_TOKEN: ${{ secrets.OP_SERVICE_ACCOUNT_TOKEN }} steps: + # Shared by every compose ps/logs/down below that runs without `op run`. + # Keep in sync with the copies in the deploy, health-check and rollback jobs. + - name: Install compose project helper + run: | + set -euo pipefail + cat > "$RUNNER_TEMP/compose-project.sh" <<'SCRIPT' + # usage: compose_project -> the stack's Compose project name + # Read from the containers' working_dir label, so a top-level `name:` or + # a dir name Compose normalises (dots, uppercase, leading _/-) still + # resolves. Falls back to the stack name when it has no containers (then + # there is nothing to act on). awk, not head: no SIGPIPE under pipefail. + compose_project() { + local p + p=$(docker ps -a \ + --filter "label=com.docker.compose.project.working_dir=${LIVE_REPO_PATH%/}/$1" \ + --format '{{.Label "com.docker.compose.project"}}' 2>/dev/null \ + | sort -u | awk 'NR == 1') || p="" + printf '%s\n' "${p:-$1}" + } + # usage: compose_p + # Runs compose against the project by name from / (no compose file there), + # so the stack's compose file is never parsed: these calls run without + # `op run`, and a file whose syntax needs ${VARS} would fail to parse. + compose_p() { + local stack="$1"; shift + (cd / && docker compose -p "$(compose_project "$stack")" "$@") + } + SCRIPT + - name: Health check critical stacks id: h run: | set -euo pipefail + # shellcheck source=/dev/null + source "$RUNNER_TEMP/compose-project.sh" CRITICAL_STACKS="${CRITICAL_STACKS:-[]}" # Mirror of dump_failed_stack used in the deploy job โ€” kept inline @@ -816,7 +886,7 @@ jobs: local lines="${FAILED_LOG_LINES:-50}" # By project name from / (no compose file there): never parses the # stack's compose file, which can need ${VARS} we don't load here. - dc() { (cd / && docker compose -p "$stack" "$@"); } + dc() { compose_p "$stack" "$@"; } echo "::group::๐Ÿ” Diagnostics: $stack" echo "โ”€โ”€ docker compose ps -a โ”€โ”€" dc ps -a 2>&1 || true @@ -840,9 +910,13 @@ jobs: failed=() for stack in $(echo "$CRITICAL_STACKS" | jq -r '.[]'); do - # By project name from / so the compose file isn't parsed (no `op run` here; - # a file whose syntax needs ${VARS}, e.g. extra_hosts, would fail to parse). - if ! services=$(cd / && docker compose -p "$stack" ps -a --format json | jq -s '.'); then + if [[ ! "$stack" =~ ^[a-zA-Z0-9._-]+$ ]]; then + failed+=("$stack") + echo "::error::invalid critical stack name: $stack" + continue + fi + # compose_p: by project name from /, so the compose file isn't parsed. + if ! services=$(compose_p "$stack" ps -a --format json | jq -s '.'); then failed+=("$stack") echo "::error::Critical stack $stack: docker compose ps failed" continue @@ -990,6 +1064,35 @@ jobs: read -ra nets <<< "$SHARED_NETWORKS" ./.compose-workflow/scripts/deployment/ensure-shared-networks.sh "${nets[@]}" + # Shared by every compose ps/logs/down below that runs without `op run`. + # Keep in sync with the copies in the deploy, health-check and rollback jobs. + - name: Install compose project helper + run: | + set -euo pipefail + cat > "$RUNNER_TEMP/compose-project.sh" <<'SCRIPT' + # usage: compose_project -> the stack's Compose project name + # Read from the containers' working_dir label, so a top-level `name:` or + # a dir name Compose normalises (dots, uppercase, leading _/-) still + # resolves. Falls back to the stack name when it has no containers (then + # there is nothing to act on). awk, not head: no SIGPIPE under pipefail. + compose_project() { + local p + p=$(docker ps -a \ + --filter "label=com.docker.compose.project.working_dir=${LIVE_REPO_PATH%/}/$1" \ + --format '{{.Label "com.docker.compose.project"}}' 2>/dev/null \ + | sort -u | awk 'NR == 1') || p="" + printf '%s\n' "${p:-$1}" + } + # usage: compose_p + # Runs compose against the project by name from / (no compose file there), + # so the stack's compose file is never parsed: these calls run without + # `op run`, and a file whose syntax needs ${VARS} would fail to parse. + compose_p() { + local stack="$1"; shift + (cd / && docker compose -p "$(compose_project "$stack")" "$@") + } + SCRIPT + - name: Resolve rollback plan id: plan run: | @@ -1098,6 +1201,8 @@ jobs: CULPRITS: ${{ steps.plan.outputs.culprits }} run: | set -euo pipefail + # shellcheck source=/dev/null + source "$RUNNER_TEMP/compose-project.sh" for stack in $(jq -r '.[]' <<<"$CULPRITS"); do # Unreachable by construction: this step only runs when mode is # per-stack, and `Resolve rollback plan` forces whole-tree if any @@ -1112,16 +1217,11 @@ jobs: # same disposition the whole-tree path gives new stacks. if jq -e --arg s "$stack" 'index($s) != null' <<<"$NEW_STACKS" >/dev/null; then echo "๐Ÿ›‘ Tearing down failed new stack $stack" - if [[ -f "$LIVE_REPO_PATH/$stack/compose.yaml" || -f "$LIVE_REPO_PATH/$stack/compose.yml" ]]; then - (cd / && docker compose -p "$stack" down) \ - || echo "::warning::down failed for $stack" - else - # Parity with the whole-tree teardown step's annotation: a - # missing compose file means containers may still be running - # with nothing left to address them by, which an operator needs - # to see rather than have silently skipped. - echo "::warning::compose file missing for failed new stack $stack" + # down targets the project label, so it no longer needs the file. + if [[ ! -f "$LIVE_REPO_PATH/$stack/compose.yaml" && ! -f "$LIVE_REPO_PATH/$stack/compose.yml" ]]; then + echo "::notice::compose file missing for failed new stack $stack; tearing down by project label" fi + compose_p "$stack" down || echo "::warning::down failed for $stack" continue fi @@ -1179,6 +1279,8 @@ jobs: NEW_STACKS: ${{ needs.prepare.outputs.new_stacks }} run: | set -euo pipefail + # shellcheck source=/dev/null + source "$RUNNER_TEMP/compose-project.sh" for stack in $(echo "$NEW_STACKS" | jq -r '.[]'); do [[ "$stack" =~ ^[a-zA-Z0-9._-]+$ ]] || { echo "::error::invalid stack name: $stack"; exit 1; } compose_file="" @@ -1188,10 +1290,10 @@ jobs: break fi done - if [[ -n "$compose_file" ]]; then - echo "๐Ÿ›‘ Tearing down new stack $stack before rollback" - (cd / && docker compose -p "$stack" down) || echo "::warning::down failed for $stack" - fi + [[ -n "$compose_file" ]] \ + || echo "::notice::compose file missing for new stack $stack; tearing down by project label" + echo "๐Ÿ›‘ Tearing down new stack $stack before rollback" + compose_p "$stack" down || echo "::warning::down failed for $stack" done - name: Reset live tree to previous SHA diff --git a/CLAUDE.md b/CLAUDE.md index a3b328b..637014b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -254,6 +254,7 @@ docker compose -f stack/compose.yaml config - **Native health verification** โ€” `docker compose up --wait` for atomic readiness - **Multi-registry auth** โ€” single `1password/load-secrets-action` step pulls all four registry credential pairs, four `docker/login-action` steps with `logout: false` and `continue-on-error: true` so a single misconfigured registry doesn't block deploys that may not pull from it - **Sequential existing-then-new** โ€” new stacks only deploy if existing stacks succeeded +- **No-env compose calls** โ€” `ps`/`logs`/`down` run without `op run`, so they go through the `compose_p` helper (installed per job into `$RUNNER_TEMP/compose-project.sh`): it resolves the stack's Compose project from the containers' `com.docker.compose.project.working_dir` label (falls back to the stack name) and runs `docker compose -p ` from `/`, so the stack's compose file โ€” whose syntax may need `${VARS}` โ€” is never parsed. Keep the three copies (deploy, health-check, rollback) in sync - **Failure diagnostics** โ€” on stack failure or health failure, dumps `docker compose ps -a`, `docker inspect` of `.State.Health.Log` (probe history with exit codes + stdout), and `docker compose logs --tail N` scoped to the failing service - **Automatic rollback** โ€” `git reset --hard ` + redeploy if deploy or health-check failed - **Discord notifications** โ€” pipeline-status icon line, removed-stacks list, commit link, user mention on failure diff --git a/scripts/deployment/ensure-shared-networks.sh b/scripts/deployment/ensure-shared-networks.sh index 28a81df..6bbcaec 100755 --- a/scripts/deployment/ensure-shared-networks.sh +++ b/scripts/deployment/ensure-shared-networks.sh @@ -40,6 +40,10 @@ for net in "$@"; do shared="${labels%%|*}" project="${labels#*|}" if [[ -n "$project" ]]; then + # deploy.yml tears stacks down by project label (`docker compose -p down`), + # which removes a project-labelled network even after that project's compose file + # switches it to `external: true` (it is only skipped while something is attached). + # Recreate a migrated network without the project label once it is idle. echo "::warning::shared network $net is owned by Compose project '$project'; its 'down' will try to remove it" fi if [[ "$shared" != "true" ]]; then From 034666efd03e3a73507affa35aab4dbe7faacec3 Mon Sep 17 00:00:00 2001 From: owine Date: Fri, 9 Oct 2026 23:54:37 -0500 Subject: [PATCH 2/3] fix(deploy): compose_p acts on every project found under the stack dir Sourcery review: when containers from more than one Compose project share a stack's working_dir label (e.g. leftovers after a project rename), compose_project returned only the first, so health checks missed the others and down left them running. compose_projects now returns all distinct matches and compose_p runs the command for each (stdin from /dev/null so compose can't eat the loop input), failing if any project's command fails. --- .github/workflows/deploy.yml | 75 +++++++++++++++++++++--------------- CLAUDE.md | 2 +- 2 files changed, 46 insertions(+), 31 deletions(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 519128f..7f585d2 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -349,26 +349,31 @@ jobs: run: | set -euo pipefail cat > "$RUNNER_TEMP/compose-project.sh" <<'SCRIPT' - # usage: compose_project -> the stack's Compose project name + # usage: compose_projects -> the stack's Compose project name(s), one per line # Read from the containers' working_dir label, so a top-level `name:` or # a dir name Compose normalises (dots, uppercase, leading _/-) still - # resolves. Falls back to the stack name when it has no containers (then - # there is nothing to act on). awk, not head: no SIGPIPE under pipefail. - compose_project() { + # resolves. Every distinct match is returned (e.g. leftovers from a renamed + # project); falls back to the stack name when it has no containers. + compose_projects() { local p p=$(docker ps -a \ --filter "label=com.docker.compose.project.working_dir=${LIVE_REPO_PATH%/}/$1" \ --format '{{.Label "com.docker.compose.project"}}' 2>/dev/null \ - | sort -u | awk 'NR == 1') || p="" + | sort -u) || p="" printf '%s\n' "${p:-$1}" } # usage: compose_p - # Runs compose against the project by name from / (no compose file there), - # so the stack's compose file is never parsed: these calls run without - # `op run`, and a file whose syntax needs ${VARS} would fail to parse. + # Runs compose against each of the stack's projects by name from / (no + # compose file there), so the stack's compose file is never parsed: these + # calls run without `op run`, and a file whose syntax needs ${VARS} would + # fail to parse. Non-zero if the command failed for any project. compose_p() { - local stack="$1"; shift - (cd / && docker compose -p "$(compose_project "$stack")" "$@") + local stack="$1" proj rc=0; shift + while IFS= read -r proj; do + [[ -n "$proj" ]] || continue + (cd / && docker compose -p "$proj" "$@" "$RUNNER_TEMP/compose-project.sh" <<'SCRIPT' - # usage: compose_project -> the stack's Compose project name + # usage: compose_projects -> the stack's Compose project name(s), one per line # Read from the containers' working_dir label, so a top-level `name:` or # a dir name Compose normalises (dots, uppercase, leading _/-) still - # resolves. Falls back to the stack name when it has no containers (then - # there is nothing to act on). awk, not head: no SIGPIPE under pipefail. - compose_project() { + # resolves. Every distinct match is returned (e.g. leftovers from a renamed + # project); falls back to the stack name when it has no containers. + compose_projects() { local p p=$(docker ps -a \ --filter "label=com.docker.compose.project.working_dir=${LIVE_REPO_PATH%/}/$1" \ --format '{{.Label "com.docker.compose.project"}}' 2>/dev/null \ - | sort -u | awk 'NR == 1') || p="" + | sort -u) || p="" printf '%s\n' "${p:-$1}" } # usage: compose_p - # Runs compose against the project by name from / (no compose file there), - # so the stack's compose file is never parsed: these calls run without - # `op run`, and a file whose syntax needs ${VARS} would fail to parse. + # Runs compose against each of the stack's projects by name from / (no + # compose file there), so the stack's compose file is never parsed: these + # calls run without `op run`, and a file whose syntax needs ${VARS} would + # fail to parse. Non-zero if the command failed for any project. compose_p() { - local stack="$1"; shift - (cd / && docker compose -p "$(compose_project "$stack")" "$@") + local stack="$1" proj rc=0; shift + while IFS= read -r proj; do + [[ -n "$proj" ]] || continue + (cd / && docker compose -p "$proj" "$@" "$RUNNER_TEMP/compose-project.sh" <<'SCRIPT' - # usage: compose_project -> the stack's Compose project name + # usage: compose_projects -> the stack's Compose project name(s), one per line # Read from the containers' working_dir label, so a top-level `name:` or # a dir name Compose normalises (dots, uppercase, leading _/-) still - # resolves. Falls back to the stack name when it has no containers (then - # there is nothing to act on). awk, not head: no SIGPIPE under pipefail. - compose_project() { + # resolves. Every distinct match is returned (e.g. leftovers from a renamed + # project); falls back to the stack name when it has no containers. + compose_projects() { local p p=$(docker ps -a \ --filter "label=com.docker.compose.project.working_dir=${LIVE_REPO_PATH%/}/$1" \ --format '{{.Label "com.docker.compose.project"}}' 2>/dev/null \ - | sort -u | awk 'NR == 1') || p="" + | sort -u) || p="" printf '%s\n' "${p:-$1}" } # usage: compose_p - # Runs compose against the project by name from / (no compose file there), - # so the stack's compose file is never parsed: these calls run without - # `op run`, and a file whose syntax needs ${VARS} would fail to parse. + # Runs compose against each of the stack's projects by name from / (no + # compose file there), so the stack's compose file is never parsed: these + # calls run without `op run`, and a file whose syntax needs ${VARS} would + # fail to parse. Non-zero if the command failed for any project. compose_p() { - local stack="$1"; shift - (cd / && docker compose -p "$(compose_project "$stack")" "$@") + local stack="$1" proj rc=0; shift + while IFS= read -r proj; do + [[ -n "$proj" ]] || continue + (cd / && docker compose -p "$proj" "$@" ` from `/`, so the stack's compose file โ€” whose syntax may need `${VARS}` โ€” is never parsed. Keep the three copies (deploy, health-check, rollback) in sync +- **No-env compose calls** โ€” `ps`/`logs`/`down` run without `op run`, so they go through the `compose_p` helper (installed per job into `$RUNNER_TEMP/compose-project.sh`): it resolves the stack's Compose project(s) from the containers' `com.docker.compose.project.working_dir` label (all distinct matches; falls back to the stack name) and runs `docker compose -p ` from `/` for each, so the stack's compose file โ€” whose syntax may need `${VARS}` โ€” is never parsed. Keep the three copies (deploy, health-check, rollback) in sync - **Failure diagnostics** โ€” on stack failure or health failure, dumps `docker compose ps -a`, `docker inspect` of `.State.Health.Log` (probe history with exit codes + stdout), and `docker compose logs --tail N` scoped to the failing service - **Automatic rollback** โ€” `git reset --hard ` + redeploy if deploy or health-check failed - **Discord notifications** โ€” pipeline-status icon line, removed-stacks list, commit link, user mention on failure From d697dcf7be3c7acd97cff7767183bf9b9702acd3 Mon Sep 17 00:00:00 2001 From: owine Date: Sat, 10 Oct 2026 00:03:02 -0500 Subject: [PATCH 3/3] fix(deploy): never fall back to a same-named project from another dir; review nits MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Independent review of #101: with no containers in the stack dir, compose_p fell back to `-p `, which matches a same-named project running from any directory โ€” teardown (now ungated on the compose file) could down it and the health check could pass on its containers. compose_projects now returns nothing in that case (down no-op, health check fails as "no containers"). The foreign check captures docker output before grep -q to avoid SIGPIPE under pipefail. Nits: update the stale project-name comment, drop the unused helper source in Deploy dockge, and build the health-check failed_stacks JSON with jq. --- .github/workflows/deploy.yml | 78 +++++++++++++++++++++++++----------- 1 file changed, 54 insertions(+), 24 deletions(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 7f585d2..0fd6a06 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -353,14 +353,25 @@ jobs: # Read from the containers' working_dir label, so a top-level `name:` or # a dir name Compose normalises (dots, uppercase, leading _/-) still # resolves. Every distinct match is returned (e.g. leftovers from a renamed - # project); falls back to the stack name when it has no containers. + # project). With no containers in the dir it falls back to the stack name, + # unless a project of that name runs from another dir (e.g. dockge's own + # /opt/dockge): then it returns nothing, so down is a no-op and the health + # check sees "no containers" rather than acting on a foreign project. compose_projects() { - local p - p=$(docker ps -a \ - --filter "label=com.docker.compose.project.working_dir=${LIVE_REPO_PATH%/}/$1" \ + local dir="${LIVE_REPO_PATH%/}/$1" p others + p=$(docker ps -a --filter "label=com.docker.compose.project.working_dir=$dir" \ --format '{{.Label "com.docker.compose.project"}}' 2>/dev/null \ | sort -u) || p="" - printf '%s\n' "${p:-$1}" + if [[ -z "$p" ]]; then + # Captured first: piping docker into `grep -q` can SIGPIPE under pipefail. + others=$(docker ps -a --filter "label=com.docker.compose.project=$1" \ + --format '{{.Label "com.docker.compose.project.working_dir"}}' 2>/dev/null) || others="" + if [[ -n "$others" ]] && grep -qvxF -- "$dir" <<<"$others"; then + return 0 + fi + p="$1" + fi + printf '%s\n' "$p" } # usage: compose_p # Runs compose against each of the stack's projects by name from / (no @@ -387,11 +398,11 @@ jobs: # if that still references a since-archived 1P item (e.g. a stack # removal that also drops its now-orphaned vars), `op run` aborts on # the dead ref and the teardown never runs, stranding the containers. - # 2. `down`/`ps`/`logs` address the project by name (`-p `, run - # from / where no compose file exists), so the compose file is never - # parsed โ€” files whose syntax needs ${VARS} (e.g. extra_hosts) would - # otherwise fail. Assumes project name == stack dir name (no top-level - # `name:`, dir names already in compose's [a-z0-9_-] form). + # 2. `down`/`ps`/`logs` go through `compose_p` (see "Install compose + # project helper"): the project is resolved from the containers' + # working_dir label and addressed by name from /, so the compose file + # is never parsed โ€” files whose syntax needs ${VARS} (e.g. + # extra_hosts) would otherwise fail. run: | set -euo pipefail # shellcheck source=/dev/null @@ -579,8 +590,6 @@ jobs: set -euo pipefail # shellcheck source=/dev/null source "$RUNNER_TEMP/compose-pull.sh" - # shellcheck source=/dev/null - source "$RUNNER_TEMP/compose-project.sh" cd "$DOCKGE_PATH" compose_pull_retry "$LIVE_REPO_PATH/compose.env" timeout "$SERVICE_STARTUP_TIMEOUT" op run --no-masking --env-file="$LIVE_REPO_PATH/compose.env" -- \ @@ -856,14 +865,25 @@ jobs: # Read from the containers' working_dir label, so a top-level `name:` or # a dir name Compose normalises (dots, uppercase, leading _/-) still # resolves. Every distinct match is returned (e.g. leftovers from a renamed - # project); falls back to the stack name when it has no containers. + # project). With no containers in the dir it falls back to the stack name, + # unless a project of that name runs from another dir (e.g. dockge's own + # /opt/dockge): then it returns nothing, so down is a no-op and the health + # check sees "no containers" rather than acting on a foreign project. compose_projects() { - local p - p=$(docker ps -a \ - --filter "label=com.docker.compose.project.working_dir=${LIVE_REPO_PATH%/}/$1" \ + local dir="${LIVE_REPO_PATH%/}/$1" p others + p=$(docker ps -a --filter "label=com.docker.compose.project.working_dir=$dir" \ --format '{{.Label "com.docker.compose.project"}}' 2>/dev/null \ | sort -u) || p="" - printf '%s\n' "${p:-$1}" + if [[ -z "$p" ]]; then + # Captured first: piping docker into `grep -q` can SIGPIPE under pipefail. + others=$(docker ps -a --filter "label=com.docker.compose.project=$1" \ + --format '{{.Label "com.docker.compose.project.working_dir"}}' 2>/dev/null) || others="" + if [[ -n "$others" ]] && grep -qvxF -- "$dir" <<<"$others"; then + return 0 + fi + p="$1" + fi + printf '%s\n' "$p" } # usage: compose_p # Runs compose against each of the stack's projects by name from / (no @@ -957,8 +977,7 @@ jobs: # Emit the culprit list BEFORE `exit 1` โ€” a step that exits # non-zero still has its already-written $GITHUB_OUTPUT honoured, # but nothing written after the exit would be. - json=$(printf '"%s",' "${failed[@]}" | sed 's/,$//') - echo "failed_stacks=[$json]" >> "$GITHUB_OUTPUT" + echo "failed_stacks=$(jq -cn '$ARGS.positional' --args "${failed[@]}")" >> "$GITHUB_OUTPUT" echo "status=failed" >> "$GITHUB_OUTPUT" exit 1 fi @@ -1084,14 +1103,25 @@ jobs: # Read from the containers' working_dir label, so a top-level `name:` or # a dir name Compose normalises (dots, uppercase, leading _/-) still # resolves. Every distinct match is returned (e.g. leftovers from a renamed - # project); falls back to the stack name when it has no containers. + # project). With no containers in the dir it falls back to the stack name, + # unless a project of that name runs from another dir (e.g. dockge's own + # /opt/dockge): then it returns nothing, so down is a no-op and the health + # check sees "no containers" rather than acting on a foreign project. compose_projects() { - local p - p=$(docker ps -a \ - --filter "label=com.docker.compose.project.working_dir=${LIVE_REPO_PATH%/}/$1" \ + local dir="${LIVE_REPO_PATH%/}/$1" p others + p=$(docker ps -a --filter "label=com.docker.compose.project.working_dir=$dir" \ --format '{{.Label "com.docker.compose.project"}}' 2>/dev/null \ | sort -u) || p="" - printf '%s\n' "${p:-$1}" + if [[ -z "$p" ]]; then + # Captured first: piping docker into `grep -q` can SIGPIPE under pipefail. + others=$(docker ps -a --filter "label=com.docker.compose.project=$1" \ + --format '{{.Label "com.docker.compose.project.working_dir"}}' 2>/dev/null) || others="" + if [[ -n "$others" ]] && grep -qvxF -- "$dir" <<<"$others"; then + return 0 + fi + p="$1" + fi + printf '%s\n' "$p" } # usage: compose_p # Runs compose against each of the stack's projects by name from / (no