Skip to content

fix(deploy): move compose_p into a tested lib; handle stale working_dir labels - #103

Merged
owine merged 1 commit into
mainfrom
fix/compose-project-helper-lib
Oct 10, 2026
Merged

owine merged 1 commit into
mainfrom
fix/compose-project-helper-lib

Conversation

@owine

@owine owine commented Oct 10, 2026 •

Copy link
Copy Markdown
Owner

Follow-up to the QA review of #101. Three findings, fixed together.

Changes

  • One copy of the helper. compose_projects/compose_p move to scripts/deployment/lib/compose-project.sh. deploy, health-check and rollback each check out compose-workflow at job.workflow_sha and install it to $RUNNER_TEMP/compose-project.sh, so the existing source lines are unchanged. This replaces three inline heredocs held together by "keep in sync" comments, and it's now shellcheck-able and unit-tested.
    • deploy: the new checkout runs before the target checkout, which git cleans the workspace. The shared-networks checkout still re-fetches later.
    • health-check: gains a checkout.
    • rollback: the checkout is now unconditional and required. continue-on-error stays on Ensure shared networks only. The runner gets this job from GitHub, so a git fetch from GitHub failing at this point is a narrow window.
  • Stale working_dir labels. Reusing a container never refreshes its working_dir label, so a stack last brought up from an older checkout or a manual up was invisible to the label lookup. A critical stack in that state failed the health check ("no containers") and rolled back on every deploy. Now, if every container of the stack's normalised project name came from a dir also named <stack>, that project is used, with a ::warning:: saying to recreate from the live tree.
  • No more silent no-op. A same-named project from a dir with a different name is still left alone (as fix(deploy): resolve compose project from container labels for no-env calls #101 intended), but now emits a ::warning:: instead of a silent down that does nothing.
  • Stack names Compose rejects. The fallback no longer runs docker compose -p <raw stack name> (rejected for uppercase or dots); the label lookup uses Compose's normalisation (compose_normalize_name).

Verification

  • scripts/testing/test-compose-project.sh: 17/17, using a fake docker on PATH. Covers name normalisation, working_dir match, top-level name:, multiple projects, stale (used + warned), foreign and mixed (skipped + warned), and compose_p per-project calls, no-op and rc propagation.
  • The other test suites still pass. shellcheck is clean. yamllint --strict is clean. actionlint reports only the known, CI-ignored job.workflow_sha items (now 6, up from 4, from the two new checkouts).
  • Read-only on piwine: the lib resolves all 15 stacks to the expected project with no warnings. The non-stacks docs and nonexistent-stack resolve to nothing. No stale labels exist there today, so this is hardening, not a live incident.

Known limitation

The stale-vs-foreign check is by dir basename. A retired dockge running from /opt/dockge alongside a repo stack named dockge would count as stale. has-dockge is false in every caller and dockge isn't a repo stack, so I left that unhandled.

Summary by Sourcery

Centralize and harden Compose project resolution across deployment jobs to recover stale labels safely and avoid acting on foreign projects.

Bug Fixes:

  • Handle stale Compose working-directory labels so critical stacks can resolve and health-check correctly after reuse from older checkouts or manual runs.
  • Warn when a same-named Compose project belongs to a foreign directory instead of silently performing a no-op.
  • Normalize stack names before fallback project lookup so names rejected by Compose are not passed through unchanged.

Enhancements:

  • Centralize compose project resolution and no-environment Compose operations in a shared, pinned helper used by deploy, health-check, and rollback jobs.

Deployment:

  • Ensure rollback always checks out the workflow-pinned compose helper while keeping shared-network setup as the only allowed-to-fail preparation step.

Documentation:

  • Update deployment documentation to describe the shared Compose helper, stale-label handling, and its test coverage.

Tests:

  • Add unit coverage for Compose name normalization, project resolution, stale and foreign labels, no-op behavior, command execution, and failure propagation.

…ir labels

QA review of #101. compose_projects is now scripts/deployment/lib/compose-project.sh,
checked out at job.workflow_sha and copied to $RUNNER_TEMP by the deploy,
health-check and rollback jobs (replaces three inline "keep in sync" copies).

- Stale working_dir: containers reused from an older checkout/manual `up` keep
  their original working_dir label, so a stack whose containers all came from
  another dir also named <stack> is now resolved by its normalised project name
  (with a ::warning::) instead of returning nothing. Previously a critical stack
  in that state failed the health check ("no containers") and triggered a
  rollback every deploy.
- Foreign same-named project: still left alone, but now with a ::warning::
  instead of a silent no-op down.
- The fallback no longer passes the raw stack name to `docker compose -p`
  (rejected for uppercase/dots); the label lookup uses the normalised name.
- health-check gains a compose-workflow checkout; rollback's checkout is now
  unconditional (its continue-on-error moves to the network step only).
- New scripts/testing/test-compose-project.sh (17 cases, fake docker on PATH).
@sourcery-ai

sourcery-ai Bot commented Oct 10, 2026 •

Copy link
Copy Markdown

Reviewer's Guide

The PR replaces three duplicated inline Compose helper implementations with a pinned, tested shared library, and hardens project resolution against stale working_dir labels while warning before skipping foreign projects or using stale metadata.

Sequence diagram for Compose project resolution and no-env commands

sequenceDiagram
    participant Job as Deploy or health-check or rollback job
    participant Helper as compose-project.sh
    participant Docker as Docker
    participant Compose as docker compose

    Job->>Helper: compose_p(stack, args)
    Helper->>Helper: compose_projects(stack)
    Helper->>Docker: docker ps -a by working_dir label
    alt matching project labels found
        Docker-->>Helper: project names
    else no matching labels
        Helper->>Helper: compose_normalize_name(stack)
        Helper->>Docker: docker ps -a by normalized project label
        alt all dirs end in stack name
            Docker-->>Helper: stale project metadata
            Helper-->>Job: warning and normalized project
        else foreign project or no containers
            Docker-->>Helper: foreign project or empty result
            Helper-->>Job: warning or no project
        end
    end
    Helper->>Compose: docker compose -p project args from /
    Compose-->>Job: command result
Loading

File-Level Changes

Change Details Files
Centralize and pin the Compose project helper across deployment jobs.
  • Move compose_projects and compose_p from duplicated workflow heredocs into a shared shell library.
  • Check out compose-workflow at job.workflow_sha and install the helper into $RUNNER_TEMP in deploy, health-check, and rollback.
  • Make rollback's helper checkout unconditional while retaining continue-on-error only for shared-network setup.
  • Document the new library and workflow installation model.
.github/workflows/deploy.yml
CLAUDE.md
scripts/deployment/lib/compose-project.sh
Resolve stale and unsafe Compose project labels without affecting foreign projects.
  • Match projects using exact live working_dir labels before fallback resolution.
  • Normalize stack names using Compose-compatible lowercase and character filtering.
  • Use same-basename working directories as a warned stale-label fallback; skip and warn for foreign or mixed directories.
  • Preserve no-op behavior when no project exists and avoid parsing stack compose files by running compose from /.
scripts/deployment/lib/compose-project.sh
.github/workflows/deploy.yml
CLAUDE.md
Add isolated unit coverage for project resolution and compose invocation behavior.
  • Test normalization, working_dir and top-level name resolution, multiple projects, stale/foreign/mixed labels, and empty results.
  • Test per-project compose calls, no-op behavior, working directory, and return-code propagation using a fake docker executable.
  • Update repository documentation and file layout to include the new test and library.
scripts/testing/test-compose-project.sh
CLAUDE.md

Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

@sourcery-ai sourcery-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey - I've found 4 issues

Prompt for AI Agents
Please address the comments from this code review:

## Individual Comments

### Comment 1
<location path="scripts/deployment/lib/compose-project.sh" line_range="53" />
<code_context>
+  [[ -n "$others" ]] || return 0
+
+  while IFS= read -r d; do
+    [[ "${d%/}" == */"$stack" ]] || stale=false
+  done <<<"$others"
+  if [[ "$stale" == true ]]; then
</code_context>
<issue_to_address>
**Foreign stacks get stopped**

When an unrelated Compose project has the normalized project name and a working directory whose basename matches the stack, `compose_projects` treats the basename match as proof that the project is a stale checkout, and `compose_p` can then run `down` against the unrelated project, stopping its containers.

Require a verified prior live-tree location or an explicit trusted-root policy before using this fallback.
</issue_to_address>

### Comment 2
<location path=".github/workflows/deploy.yml" line_range="348" />
<code_context>
+      # Scripts pinned to this workflow's commit, as in the prepare job. The
+      # target checkout below cleans the workspace, so the helper is copied out
+      # to $RUNNER_TEMP first (the shared-networks checkout re-fetches later).
+      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1  # v7.0.1
+        if: steps.skip-gate.outputs.skipped == 'false'
+        with:
</code_context>
<issue_to_address>
**Rollback recovery gets skipped**

When a required compose-workflow checkout fails after deployment starts, the health-check checkout failure triggers rollback, while a rollback checkout failure prevents the plan and restore steps from running; GitHub skips those recovery steps and leaves the failed deployment unrestored.

Make the helper available to recovery without a required network fetch, or provide a fallback helper path.

Also at `.github/workflows/deploy.yml:829-833`, `.github/workflows/deploy.yml:1013`, `.github/workflows/deploy.yml:1022`, `.github/workflows/deploy.yml:1039`, `.github/workflows/deploy.yml:1081-1082`.
</issue_to_address>

### Comment 3
<location path="scripts/deployment/lib/compose-project.sh" line_range="38-39" />
<code_context>
-          compose_projects() {
-            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=""
-            if [[ -z "$p" ]]; then
-              # Captured first: piping docker into `grep -q` can SIGPIPE under pipefail.
</code_context>
<issue_to_address>
**Teardown failures go unnoticed**

When docker is unavailable or either `docker ps` lookup fails, `compose_projects` suppresses the lookup errors and converts failed queries to empty results, so `compose_p` returns success without running `down`; containers remain running and teardown callers see no failure.

Preserve and propagate lookup failures instead of treating them as successful empty results.

Also at `scripts/deployment/lib/compose-project.sh:47-50`.
</issue_to_address>

### Comment 4
<location path="scripts/deployment/lib/compose-project.sh" line_range="47" />
<code_context>
+
+  name=$(compose_normalize_name "$stack")
+  [[ -n "$name" ]] || return 0
+  others=$(docker ps -a --filter "label=com.docker.compose.project=$name" \
+        --format '{{.Label "com.docker.compose.project.working_dir"}}' 2>/dev/null \
+        | sort -u) || others=""
</code_context>
<issue_to_address>
**Running stacks appear empty**

When containers have stale `working_dir` labels and their project name comes from a custom Compose `name:` or `COMPOSE_PROJECT_NAME`, `compose_projects` falls back to searching only for the normalized stack name, so it misses those containers; `compose_p` skips Compose, health checks report no containers, and teardown leaves the project running.

Resolve the project name for stale-directory containers instead of limiting the fallback to the normalized stack name.
</issue_to_address>

Sourcery assessment

Needs a human reviewer. 4 findings to address first, and if project-label resolution or the pinned helper checkout is wrong, deployment teardown or rollback could target the wrong Compose project, causing a production outage or leaving a stack unrestored. Reverting prevents future occurrences but does not restore containers or undo an outage that already happened.

Blocking findings: scripts/deployment/lib/compose-project.sh:53, .github/workflows/deploy.yml:348, scripts/deployment/lib/compose-project.sh:39, scripts/deployment/lib/compose-project.sh:47


Sourcery is free for open source - if you like our reviews please consider sharing them ✨

[[ -n "$others" ]] || return 0

while IFS= read -r d; do
[[ "${d%/}" == */"$stack" ]] || stale=false

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 High · Foreign stacks get stopped

When an unrelated Compose project has the normalized project name and a working directory whose basename matches the stack, compose_projects treats the basename match as proof that the project is a stale checkout, and compose_p can then run down against the unrelated project, stopping its containers.

Require a verified prior live-tree location or an explicit trusted-root policy before using this fallback.

Prompt for AI agents
In `scripts/deployment/lib/compose-project.sh` at line 53:

**Foreign stacks get stopped**

When an unrelated Compose project has the normalized project name and a working directory whose basename matches the stack, `compose_projects` treats the basename match as proof that the project is a stale checkout, and `compose_p` can then run `down` against the unrelated project, stopping its containers.

Require a verified prior live-tree location or an explicit trusted-root policy before using this fallback.

# Scripts pinned to this workflow's commit, as in the prepare job. The
# target checkout below cleans the workspace, so the helper is copied out
# to $RUNNER_TEMP first (the shared-networks checkout re-fetches later).
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 High · Rollback recovery gets skipped

When a required compose-workflow checkout fails after deployment starts, the health-check checkout failure triggers rollback, while a rollback checkout failure prevents the plan and restore steps from running; GitHub skips those recovery steps and leaves the failed deployment unrestored.

Make the helper available to recovery without a required network fetch, or provide a fallback helper path.

Also at .github/workflows/deploy.yml:829-833, .github/workflows/deploy.yml:1013, .github/workflows/deploy.yml:1022, .github/workflows/deploy.yml:1039, .github/workflows/deploy.yml:1081-1082.

Prompt for AI agents
In `.github/workflows/deploy.yml` at line 348:

**Rollback recovery gets skipped**

When a required compose-workflow checkout fails after deployment starts, the health-check checkout failure triggers rollback, while a rollback checkout failure prevents the plan and restore steps from running; GitHub skips those recovery steps and leaves the failed deployment unrestored.

Make the helper available to recovery without a required network fetch, or provide a fallback helper path.

Also at `.github/workflows/deploy.yml:829-833`, `.github/workflows/deploy.yml:1013`, `.github/workflows/deploy.yml:1022`, `.github/workflows/deploy.yml:1039`, `.github/workflows/deploy.yml:1081-1082`.

Comment on lines +38 to +39
--format '{{.Label "com.docker.compose.project"}}' 2>/dev/null \
| sort -u) || p=""

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium · Teardown failures go unnoticed

When docker is unavailable or either docker ps lookup fails, compose_projects suppresses the lookup errors and converts failed queries to empty results, so compose_p returns success without running down; containers remain running and teardown callers see no failure.

Preserve and propagate lookup failures instead of treating them as successful empty results.

Also at scripts/deployment/lib/compose-project.sh:47-50.

Prompt for AI agents
In `scripts/deployment/lib/compose-project.sh` at lines 38-39:

**Teardown failures go unnoticed**

When docker is unavailable or either `docker ps` lookup fails, `compose_projects` suppresses the lookup errors and converts failed queries to empty results, so `compose_p` returns success without running `down`; containers remain running and teardown callers see no failure.

Preserve and propagate lookup failures instead of treating them as successful empty results.

Also at `scripts/deployment/lib/compose-project.sh:47-50`.


name=$(compose_normalize_name "$stack")
[[ -n "$name" ]] || return 0
others=$(docker ps -a --filter "label=com.docker.compose.project=$name" \

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 High · Running stacks appear empty

When containers have stale working_dir labels and their project name comes from a custom Compose name: or COMPOSE_PROJECT_NAME, compose_projects falls back to searching only for the normalized stack name, so it misses those containers; compose_p skips Compose, health checks report no containers, and teardown leaves the project running.

Resolve the project name for stale-directory containers instead of limiting the fallback to the normalized stack name.

Prompt for AI agents
In `scripts/deployment/lib/compose-project.sh` at line 47:

**Running stacks appear empty**

When containers have stale `working_dir` labels and their project name comes from a custom Compose `name:` or `COMPOSE_PROJECT_NAME`, `compose_projects` falls back to searching only for the normalized stack name, so it misses those containers; `compose_p` skips Compose, health checks report no containers, and teardown leaves the project running.

Resolve the project name for stale-directory containers instead of limiting the fallback to the normalized stack name.

@owine
owine merged commit 7512041 into main Oct 10, 2026
3 checks passed
@owine
owine deleted the fix/compose-project-helper-lib branch October 10, 2026 13:22
owine added a commit that referenced this pull request Oct 10, 2026
)

Sourcery review of #103. compose_projects swallowed docker ps failures
(|| p=""), so a broken daemon made compose_p return 0 without running
down: teardowns and failure cleanups looked successful while the containers
kept running. Both lookups now capture docker's status before sorting (no
reliance on the caller's pipefail), warn and return 1; compose_p captures
the lookup result instead of reading a process substitution (whose exit
status is lost) and returns 1 without calling compose. docker's own stderr
is no longer discarded.

Every call site already handles non-zero: teardowns log 'down failed', the
health check reports 'docker compose ps failed', diagnostics use || true.
Tests: 3 new cases (20/20); all 3 fail against the previous helper.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant