From 9429a7c8441c1a65c83c278e4644872f275003f6 Mon Sep 17 00:00:00 2001 From: Goon Date: Tue, 17 Mar 2026 12:44:18 +0700 Subject: [PATCH] ci: publish Docker images to GHCR and Docker Hub (#237) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(ci): add node/python/full runtime variants to Docker publish Add runtime image variants alongside existing build-tag variants: - :node (pre-installed Node.js) - :python (pre-installed Python) - :full (Node.js + Python + all skill deps) * feat(ci): add install scripts, release binaries, and Discord notifications - scripts/install.sh: one-liner binary installer from GitHub Releases - scripts/setup-docker.sh: interactive Docker setup with variant selection (alpine/node/python/full), auto-generates .env + docker-compose.yaml with persistent volumes for data, skills, workspace, storage - release.yaml: build cross-platform binaries (linux/darwin × amd64/arm64) and attach to GitHub Release, notify Discord on new releases - .gitignore: ignore Apple Double (._*) files from external volumes * docs(docker): add pre-built image references and update docker-compose workflows - Add `image:` directives to docker-compose.yml, docker-compose.selfservice.yml, and docker-compose.upgrade.yml pointing to ghcr.io/nextlevelbuilder/goclaw pre-built images - Add Docker Hub mirror references (digitop/goclaw) for public access - Document available image tags (latest, node, python, full, otel, tsnet, redis) with descriptions - Update README with pre-built image pull instructions and semver tag examples - Clarify deployment workflows: use pre-built images by default (no --build), add --build only when building from source - Update upgrade workflow to pull pre-built images instead of rebuild - Add note about build args requirement for otel/tsnet/redis overlays - Update .dockerignore to exclude macOS temp files (._*) * fix(ci): use claude_code_oauth_token instead of anthropic_api_key --- .dockerignore | 1 + .github/workflows/claude-code-review.yml | 2 +- .github/workflows/claude.yml | 2 +- .github/workflows/docker-publish.yaml | 74 +++++-- .github/workflows/release.yaml | 76 ++++++- .gitignore | 4 +- README.md | 61 +++++- docker-compose.selfservice.yml | 1 + docker-compose.upgrade.yml | 1 + docker-compose.yml | 10 +- scripts/install.sh | 88 ++++++++ scripts/setup-docker.sh | 254 +++++++++++++++++++++++ 12 files changed, 540 insertions(+), 34 deletions(-) create mode 100644 scripts/install.sh create mode 100644 scripts/setup-docker.sh diff --git a/.dockerignore b/.dockerignore index 537b9b33..70d41f48 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,5 +1,6 @@ .git .github +._* .env* .dockerignore *.md diff --git a/.github/workflows/claude-code-review.yml b/.github/workflows/claude-code-review.yml index 4f6145be..b5e8cfd4 100644 --- a/.github/workflows/claude-code-review.yml +++ b/.github/workflows/claude-code-review.yml @@ -35,7 +35,7 @@ jobs: id: claude-review uses: anthropics/claude-code-action@v1 with: - anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} + claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} plugin_marketplaces: 'https://github.com/anthropics/claude-code.git' plugins: 'code-review@claude-code-plugins' prompt: '/code-review:code-review ${{ github.repository }}/pull/${{ github.event.pull_request.number }}' diff --git a/.github/workflows/claude.yml b/.github/workflows/claude.yml index 79fe0564..d300267f 100644 --- a/.github/workflows/claude.yml +++ b/.github/workflows/claude.yml @@ -34,7 +34,7 @@ jobs: id: claude uses: anthropics/claude-code-action@v1 with: - anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} + claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} # This is an optional setting that allows Claude to read CI results on PRs additional_permissions: | diff --git a/.github/workflows/docker-publish.yaml b/.github/workflows/docker-publish.yaml index 28fda65d..bbf61faf 100644 --- a/.github/workflows/docker-publish.yaml +++ b/.github/workflows/docker-publish.yaml @@ -19,31 +19,64 @@ jobs: strategy: matrix: include: + # ── Runtime variants ── - variant: latest - enable_otel: "false" - enable_tsnet: "false" - enable_redis: "false" suffix: "" - - variant: otel - enable_otel: "true" - enable_tsnet: "false" - enable_redis: "false" - suffix: "-otel" - - variant: tsnet - enable_otel: "false" - enable_tsnet: "true" - enable_redis: "false" - suffix: "-tsnet" - - variant: redis enable_otel: "false" enable_tsnet: "false" - enable_redis: "true" - suffix: "-redis" + enable_redis: "false" + enable_node: "false" + enable_python: "false" + enable_full_skills: "false" + - variant: node + suffix: "-node" + enable_otel: "false" + enable_tsnet: "false" + enable_redis: "false" + enable_node: "true" + enable_python: "false" + enable_full_skills: "false" + - variant: python + suffix: "-python" + enable_otel: "false" + enable_tsnet: "false" + enable_redis: "false" + enable_node: "false" + enable_python: "true" + enable_full_skills: "false" - variant: full - enable_otel: "true" - enable_tsnet: "true" - enable_redis: "true" suffix: "-full" + enable_otel: "false" + enable_tsnet: "false" + enable_redis: "false" + enable_node: "true" + enable_python: "true" + enable_full_skills: "true" + # ── Build-tag variants ── + - variant: otel + suffix: "-otel" + enable_otel: "true" + enable_tsnet: "false" + enable_redis: "false" + enable_node: "false" + enable_python: "false" + enable_full_skills: "false" + - variant: tsnet + suffix: "-tsnet" + enable_otel: "false" + enable_tsnet: "true" + enable_redis: "false" + enable_node: "false" + enable_python: "false" + enable_full_skills: "false" + - variant: redis + suffix: "-redis" + enable_otel: "false" + enable_tsnet: "false" + enable_redis: "true" + enable_node: "false" + enable_python: "false" + enable_full_skills: "false" steps: - name: Checkout @@ -93,6 +126,9 @@ jobs: ENABLE_OTEL=${{ matrix.enable_otel }} ENABLE_TSNET=${{ matrix.enable_tsnet }} ENABLE_REDIS=${{ matrix.enable_redis }} + ENABLE_NODE=${{ matrix.enable_node }} + ENABLE_PYTHON=${{ matrix.enable_python }} + ENABLE_FULL_SKILLS=${{ matrix.enable_full_skills }} VERSION=${{ github.ref_name }} cache-from: type=gha,scope=${{ matrix.variant }} cache-to: type=gha,mode=max,scope=${{ matrix.variant }} diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 493b559b..31c47ac3 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -10,12 +10,86 @@ permissions: jobs: release: runs-on: ubuntu-latest + outputs: + version: ${{ steps.semrel.outputs.version }} + released: ${{ steps.semrel.outputs.version != '' }} steps: - uses: actions/checkout@v4 with: fetch-depth: 0 - - uses: go-semantic-release/action@v1 + - id: semrel + uses: go-semantic-release/action@v1 with: github-token: ${{ secrets.GITHUB_TOKEN }} allow-initial-development-versions: true + + # Build cross-platform binaries and attach to release + build-binaries: + needs: release + if: needs.release.outputs.released == 'true' + runs-on: ubuntu-latest + strategy: + matrix: + include: + - goos: linux + goarch: amd64 + - goos: linux + goarch: arm64 + - goos: darwin + goarch: amd64 + - goos: darwin + goarch: arm64 + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-go@v5 + with: + go-version-file: go.mod + cache-dependency-path: go.sum + + - name: Build binary + env: + GOOS: ${{ matrix.goos }} + GOARCH: ${{ matrix.goarch }} + VERSION: v${{ needs.release.outputs.version }} + run: | + CGO_ENABLED=0 go build \ + -ldflags="-s -w -X github.com/nextlevelbuilder/goclaw/cmd.Version=${VERSION}" \ + -o goclaw . + tar -czf "goclaw-${{ needs.release.outputs.version }}-${{ matrix.goos }}-${{ matrix.goarch }}.tar.gz" goclaw + + - name: Upload to release + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + gh release upload "v${{ needs.release.outputs.version }}" \ + "goclaw-${{ needs.release.outputs.version }}-${{ matrix.goos }}-${{ matrix.goarch }}.tar.gz" \ + --clobber + + # Notify Discord on new release + notify-discord: + needs: [release, build-binaries] + if: needs.release.outputs.released == 'true' + runs-on: ubuntu-latest + steps: + - name: Send Discord notification + env: + DISCORD_WEBHOOK_URL: ${{ secrets.DISCORD_WEBHOOK_URL }} + VERSION: v${{ needs.release.outputs.version }} + run: | + curl -fsSL -H "Content-Type: application/json" \ + -d "{ + \"embeds\": [{ + \"title\": \"GoClaw ${VERSION} Released\", + \"url\": \"https://github.com/${{ github.repository }}/releases/tag/${VERSION}\", + \"color\": 5814783, + \"fields\": [ + {\"name\": \"Docker\", \"value\": \"\`docker pull digitop/goclaw:latest\`\", \"inline\": false}, + {\"name\": \"Install\", \"value\": \"\`curl -fsSL https://raw.githubusercontent.com/${{ github.repository }}/main/scripts/install.sh | bash\`\", \"inline\": false} + ], + \"footer\": {\"text\": \"${{ github.repository }}\"}, + \"timestamp\": \"$(date -u +%Y-%m-%dT%H:%M:%SZ)\" + }] + }" \ + "$DISCORD_WEBHOOK_URL" diff --git a/.gitignore b/.gitignore index 87bb717d..3317a008 100644 --- a/.gitignore +++ b/.gitignore @@ -10,6 +10,7 @@ openclaw-go # OS .DS_Store +._* # Environment .env* @@ -36,6 +37,7 @@ tmp deploy-*.sh docs/technical-diaries +docs/zuey plans release-manifest.json k8s-*/* @@ -43,4 +45,4 @@ k8s-*/* # Test credentials (never commit) tests/**/creds.json ui/simple-saas -*.tar \ No newline at end of file +*.tar diff --git a/README.md b/README.md index bfa19fd1..717a1bc2 100644 --- a/README.md +++ b/README.md @@ -344,10 +344,14 @@ The script creates `.env` from `.env.example`, auto-generates `GOCLAW_ENCRYPTION ```bash # Recommended: Gateway + Web Dashboard (http://localhost:3000) +# Pull pre-built images: +docker compose -f docker-compose.yml -f docker-compose.postgres.yml -f docker-compose.selfservice.yml up -d + +# Or build from source: docker compose -f docker-compose.yml -f docker-compose.postgres.yml -f docker-compose.selfservice.yml up -d --build # Without dashboard -docker compose -f docker-compose.yml -f docker-compose.postgres.yml up -d --build +docker compose -f docker-compose.yml -f docker-compose.postgres.yml up -d # + OpenTelemetry tracing (Jaeger at http://localhost:16686) docker compose -f docker-compose.yml -f docker-compose.postgres.yml -f docker-compose.otel.yml up -d --build @@ -440,7 +444,40 @@ CGO_ENABLED=0 go build -ldflags="-s -w" -tags "otel,tsnet" -o goclaw . > Optional features are gated behind build tags to avoid binary bloat. OTel adds ~11 MB (gRPC + protobuf). Tailscale adds ~20 MB (tsnet + WireGuard). The base build includes in-app tracing backed by PostgreSQL and localhost-only access. -### Docker Build +### Docker Images (Pre-built) + +Pre-built multi-arch images (linux/amd64 + linux/arm64) are published to **GHCR** and **Docker Hub** on every release: + +```bash +# GHCR (recommended) +docker pull ghcr.io/nextlevelbuilder/goclaw:latest + +# Docker Hub +docker pull digitop/goclaw:latest +``` + +**Available tags:** + +| Tag | Description | +| ---------- | -------------------------------------- | +| `latest` | Base image (~50 MB Alpine) | +| `node` | + Node.js runtime for JS tools | +| `python` | + Python runtime for Python tools | +| `full` | + Node.js + Python + all bundled skills | +| `otel` | + OpenTelemetry tracing support | +| `tsnet` | + Tailscale VPN mesh listener | +| `redis` | + Redis cache backend | + +Semver tags are also available: `1.0.0`, `1.0`, etc. (e.g. `ghcr.io/nextlevelbuilder/goclaw:1.0.0-python`). + +**Web Dashboard:** + +```bash +docker pull ghcr.io/nextlevelbuilder/goclaw-web:latest +docker pull digitop/goclaw-web:latest +``` + +### Docker Build (from source) ```bash # Standard image (~50MB Alpine) @@ -682,13 +719,13 @@ Composable files for different deployment scenarios: # Prepare .env (auto-generates secrets, prompts for API key) chmod +x prepare-env.sh && ./prepare-env.sh -# Managed (PostgreSQL) -docker compose -f docker-compose.yml -f docker-compose.postgres.yml up -d --build +# Using pre-built images (no --build flag): +docker compose -f docker-compose.yml -f docker-compose.postgres.yml up -d # Managed + Web Dashboard (http://localhost:3000) docker compose -f docker-compose.yml \ -f docker-compose.postgres.yml \ - -f docker-compose.selfservice.yml up -d --build + -f docker-compose.selfservice.yml up -d # Managed + Web Dashboard + OpenTelemetry (Jaeger UI at http://localhost:16686) docker compose -f docker-compose.yml \ @@ -705,15 +742,21 @@ docker compose -f docker-compose.yml \ curl http://localhost:18790/health ``` +> **Note:** Omit `--build` to use pre-built images from GHCR. Add `--build` to build from source. Overlays that require build args (otel, tsnet, redis, sandbox) need `--build`. + ### Upgrading (Docker Compose) -**Simple upgrade** — pull the latest code, rebuild, and restart. The entrypoint automatically runs `goclaw upgrade` (schema migrations + data hooks) before starting: +**Simple upgrade** — pull the latest images and restart. The entrypoint automatically runs `goclaw upgrade` (schema migrations + data hooks) before starting: ```bash -# Pull latest code -git pull +# Using pre-built images (recommended): +docker compose -f docker-compose.yml -f docker-compose.postgres.yml \ + -f docker-compose.selfservice.yml pull +docker compose -f docker-compose.yml -f docker-compose.postgres.yml \ + -f docker-compose.selfservice.yml up -d -# Rebuild and restart (auto-upgrades database on start) +# Or build from source: +git pull docker compose -f docker-compose.yml -f docker-compose.postgres.yml \ -f docker-compose.selfservice.yml up -d --build ``` diff --git a/docker-compose.selfservice.yml b/docker-compose.selfservice.yml index a88d5a00..29e2dc34 100644 --- a/docker-compose.selfservice.yml +++ b/docker-compose.selfservice.yml @@ -7,6 +7,7 @@ services: goclaw-ui: + image: ghcr.io/nextlevelbuilder/goclaw-web:latest build: context: ./ui/web dockerfile: Dockerfile diff --git a/docker-compose.upgrade.yml b/docker-compose.upgrade.yml index 1f058848..50b8df71 100644 --- a/docker-compose.upgrade.yml +++ b/docker-compose.upgrade.yml @@ -14,6 +14,7 @@ services: upgrade: + image: ghcr.io/nextlevelbuilder/goclaw:latest build: context: . dockerfile: Dockerfile diff --git a/docker-compose.yml b/docker-compose.yml index 85342035..ddfd9ff1 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,14 +1,20 @@ # Base docker-compose — shared service definition. +# Pre-built images: ghcr.io/nextlevelbuilder/goclaw (also on Docker Hub: digitop/goclaw) +# # Combine with overlays for your deployment: # -# Recommended (+ Web Dashboard): +# Recommended (+ Web Dashboard, pre-built images): +# docker compose -f docker-compose.yml -f docker-compose.postgres.yml -f docker-compose.selfservice.yml up -d +# +# Build from source (add --build): # docker compose -f docker-compose.yml -f docker-compose.postgres.yml -f docker-compose.selfservice.yml up -d --build # -# Without dashboard: docker compose -f docker-compose.yml -f docker-compose.postgres.yml up -d --build +# Without dashboard: docker compose -f docker-compose.yml -f docker-compose.postgres.yml up -d # With OTel tracing: docker compose -f docker-compose.yml -f docker-compose.postgres.yml -f docker-compose.otel.yml up -d --build services: goclaw: + image: ghcr.io/nextlevelbuilder/goclaw:latest build: context: . dockerfile: Dockerfile diff --git a/scripts/install.sh b/scripts/install.sh new file mode 100644 index 00000000..58bb2e80 --- /dev/null +++ b/scripts/install.sh @@ -0,0 +1,88 @@ +#!/usr/bin/env bash +# GoClaw installer — downloads the latest binary from GitHub Releases. +# +# Usage: +# curl -fsSL https://raw.githubusercontent.com/nextlevelbuilder/goclaw/main/scripts/install.sh | bash +# curl -fsSL ... | bash -s -- --version v1.30.0 +# curl -fsSL ... | bash -s -- --dir /opt/goclaw +# +# Supported: Linux (amd64/arm64), macOS (amd64/arm64) + +set -euo pipefail + +REPO="nextlevelbuilder/goclaw" +INSTALL_DIR="${GOCLAW_INSTALL_DIR:-/usr/local/bin}" +VERSION="" + +# ── Parse args ── +while [[ $# -gt 0 ]]; do + case "$1" in + --version) VERSION="$2"; shift 2 ;; + --dir) INSTALL_DIR="$2"; shift 2 ;; + --help|-h) + echo "Usage: install.sh [--version v1.x.x] [--dir /path]" + exit 0 + ;; + *) echo "Unknown option: $1"; exit 1 ;; + esac +done + +# ── Detect OS/arch ── +OS="$(uname -s | tr '[:upper:]' '[:lower:]')" +ARCH="$(uname -m)" +case "$ARCH" in + x86_64) ARCH="amd64" ;; + aarch64|arm64) ARCH="arm64" ;; + *) echo "Unsupported architecture: $ARCH"; exit 1 ;; +esac + +case "$OS" in + linux|darwin) ;; + *) echo "Unsupported OS: $OS"; exit 1 ;; +esac + +# ── Resolve version ── +if [ -z "$VERSION" ]; then + echo "Fetching latest release..." + VERSION="$(curl -fsSL "https://api.github.com/repos/${REPO}/releases/latest" | grep '"tag_name"' | head -1 | sed 's/.*"tag_name": *"\([^"]*\)".*/\1/')" +fi +echo "Installing GoClaw ${VERSION} (${OS}/${ARCH})..." + +# ── Download ── +ASSET="goclaw-${VERSION#v}-${OS}-${ARCH}.tar.gz" +URL="https://github.com/${REPO}/releases/download/${VERSION}/${ASSET}" +TMP="$(mktemp -d)" +trap 'rm -rf "$TMP"' EXIT + +echo "Downloading ${URL}..." +curl -fsSL -o "${TMP}/${ASSET}" "$URL" + +# ── Extract & install ── +tar -xzf "${TMP}/${ASSET}" -C "$TMP" + +# Check write permission, use sudo if needed +if [ -w "$INSTALL_DIR" ]; then + cp "${TMP}/goclaw" "${INSTALL_DIR}/goclaw" + chmod +x "${INSTALL_DIR}/goclaw" +else + echo "Installing to ${INSTALL_DIR} (requires sudo)..." + sudo cp "${TMP}/goclaw" "${INSTALL_DIR}/goclaw" + sudo chmod +x "${INSTALL_DIR}/goclaw" +fi + +echo "" +echo "GoClaw ${VERSION} installed to ${INSTALL_DIR}/goclaw" +echo "" +echo "Next steps:" +echo " 1. Set up PostgreSQL (pgvector):" +echo " docker run -d --name goclaw-pg -p 5432:5432 -e POSTGRES_PASSWORD=goclaw pgvector/pgvector:pg18" +echo "" +echo " 2. Run database migrations:" +echo " export GOCLAW_POSTGRES_DSN='postgres://postgres:goclaw@localhost:5432/postgres?sslmode=disable'" +echo " goclaw migrate up" +echo "" +echo " 3. Start the onboard wizard:" +echo " goclaw onboard" +echo "" +echo " 4. Start the gateway:" +echo " goclaw" diff --git a/scripts/setup-docker.sh b/scripts/setup-docker.sh new file mode 100644 index 00000000..20de9ef9 --- /dev/null +++ b/scripts/setup-docker.sh @@ -0,0 +1,254 @@ +#!/usr/bin/env bash +# GoClaw Docker setup — generates .env and docker-compose command for your chosen variant. +# +# Usage: +# ./scripts/setup-docker.sh # Interactive mode +# ./scripts/setup-docker.sh --variant full --with-ui +# ./scripts/setup-docker.sh --variant alpine --dev +# +# Variants: +# alpine — Base image (~50 MB), no runtimes +# node — Alpine + Node.js pre-installed +# python — Alpine + Python pre-installed +# full — Alpine + Node.js + Python + all skill dependencies +# +# Flags: +# --dev Mount local source for development (live reload) +# --with-ui Include web dashboard +# --port N Gateway port (default: 18790) +# --ui-port N Dashboard port (default: 3000) +# --pg-port N PostgreSQL port (default: 5432) + +set -euo pipefail + +# ── Defaults ── +VARIANT="" +DEV_MODE=false +WITH_UI=false +PORT="${GOCLAW_PORT:-18790}" +UI_PORT="${GOCLAW_UI_PORT:-3000}" +PG_PORT="${POSTGRES_PORT:-5432}" +DATA_DIR="${GOCLAW_DATA_DIR:-./goclaw-data}" + +# ── Parse args ── +while [[ $# -gt 0 ]]; do + case "$1" in + --variant) VARIANT="$2"; shift 2 ;; + --dev) DEV_MODE=true; shift ;; + --with-ui) WITH_UI=true; shift ;; + --port) PORT="$2"; shift 2 ;; + --ui-port) UI_PORT="$2"; shift 2 ;; + --pg-port) PG_PORT="$2"; shift 2 ;; + --data-dir) DATA_DIR="$2"; shift 2 ;; + --help|-h) + head -20 "$0" | grep '^#' | sed 's/^# \?//' + exit 0 + ;; + *) echo "Unknown option: $1"; exit 1 ;; + esac +done + +# ── Interactive variant selection ── +if [ -z "$VARIANT" ]; then + echo "Select GoClaw Docker variant:" + echo "" + echo " 1) alpine — Base image (~50 MB), no runtimes" + echo " 2) node — + Node.js (for JS/TS skills)" + echo " 3) python — + Python (for Python skills)" + echo " 4) full — + Node.js + Python + all skill deps" + echo "" + read -rp "Choice [1-4, default=1]: " choice + case "${choice:-1}" in + 1|alpine) VARIANT="alpine" ;; + 2|node) VARIANT="node" ;; + 3|python) VARIANT="python" ;; + 4|full) VARIANT="full" ;; + *) echo "Invalid choice"; exit 1 ;; + esac +fi + +# ── Resolve Docker image tag ── +DOCKER_IMAGE="digitop/goclaw" +case "$VARIANT" in + alpine) IMAGE_TAG="latest" ;; + node) IMAGE_TAG="node" ;; + python) IMAGE_TAG="python" ;; + full) IMAGE_TAG="full" ;; + *) echo "Unknown variant: $VARIANT"; exit 1 ;; +esac + +echo "" +echo "Setting up GoClaw (${VARIANT})..." + +# ── Create data directories ── +mkdir -p "${DATA_DIR}"/{config,data,workspace,skills,storage} + +# ── Generate secrets if not present ── +ENV_FILE="${DATA_DIR}/.env" +if [ ! -f "$ENV_FILE" ]; then + GATEWAY_TOKEN="$(openssl rand -hex 32 2>/dev/null || head -c 64 /dev/urandom | od -An -tx1 | tr -d ' \n')" + ENCRYPTION_KEY="$(openssl rand -hex 16 2>/dev/null || head -c 32 /dev/urandom | od -An -tx1 | tr -d ' \n')" + PG_PASSWORD="$(openssl rand -hex 12 2>/dev/null || head -c 24 /dev/urandom | od -An -tx1 | tr -d ' \n')" + + cat > "$ENV_FILE" < "$COMPOSE_FILE" <> "$COMPOSE_FILE" <> "$COMPOSE_FILE" <> "$COMPOSE_FILE" <> "$COMPOSE_FILE" <> "$COMPOSE_FILE" <