3 Commits

Author SHA1 Message Date
hibna d50a7fd049 Install the docker CLI in jobs that build images
CI / Docker Build (push) Has been skipped
CI / Lint & Type Check (push) Successful in 4m4s
CI / Daemon Build & Test (push) Successful in 6m19s
CI / Publish images (push) Failing after 2m15s
The runner executes jobs inside a container that ships no docker client,
so the publish job died on its first command:

  /var/run/act/workflow/1: line 3: docker: command not found

act_runner does mount the host's socket into job containers, so only the
client is missing. Both image jobs now fetch the static binary when it is
absent and then check that the socket answers, because "no client" and
"no daemon" are different problems and the log should say which one it
hit. The docker build-test job needed the same treatment — it is skipped
on tags, so it had never reached that command either.

Verified in a container without a docker client: with the socket mounted
the step installs the client and builds the web image; without it the
step fails with the runner-configuration message instead of a confusing
connection error.
2026-08-02 23:13:55 +03:00
hibna 55e0a3cde6 Make the API and web images build and run
CI / Docker Build (push) Has been skipped
CI / Lint & Type Check (push) Successful in 4m13s
CI / Daemon Build & Test (push) Successful in 6m24s
CI / Publish images (push) Failing after 12s
The image builds were never exercised: the docker job needs lint, and
lint was failing, so nothing downstream of it ever ran. Four defects
had accumulated behind that gate, each fatal on its own.

- pnpm creates no node_modules for a package without dependencies, and
  @source/shared has none. Three COPY lines named that path and failed.
- The production stage copied apps/api/dist, which tsc never wrote:
  tsconfig.base.json sets noEmit and no package overrides it. Rather
  than turn emit on — every @source/* package points main at its
  TypeScript source, and @source/proto derives daemon.proto's location
  from a /src/index.ts module URL — the stage now runs the sources
  through tsx, exactly as the migrate stage has always done.
- tsx lives in apps/api/node_modules/.bin under pnpm's isolated layout,
  so the command only resolves from the package directory.
- Both healthchecks probed localhost, which musl resolves to ::1 while
  the servers bind IPv4. Every probe was refused, so the containers sat
  unhealthy forever — and `docker compose up --wait`, which is how a
  panel installs this stack, waits for healthy.

Also fixed the postgres healthcheck in both compose files. pg_isready
without -h asks over the unix socket, which answers during the image's
init phase before the server listens on TCP; the migrate container then
started and died with ECONNREFUSED against a container Compose had just
called healthy.

Verified by running the full stack from docker-compose.panel.yml with
locally built images: migrations and seed complete, api, web, daemon,
postgres and redis all report healthy, and /api/health answers 200
through the web container's proxy.
2026-08-02 21:48:51 +03:00
hibna cb3a90be35 Build the daemon with Rust 1.97
Dependencies now ship edition 2024, which Cargo 1.83 refuses to parse:

  feature `edition2024` is required
  ... not stabilized in this version of Cargo (1.83.0)

The pin lived in two places and both had to move, or the daemon image
would have failed the same way the CI job did.
2026-08-02 21:48:33 +03:00
6 changed files with 101 additions and 20 deletions
+46 -1
View File
@@ -10,7 +10,12 @@ on:
env:
NODE_VERSION: "20"
PNPM_VERSION: "9.15.4"
RUST_TOOLCHAIN: "1.83"
# 1.85 is the floor: dependencies now ship edition 2024, which older
# Cargo refuses to even parse. Keep this in step with the toolchain
# pinned in apps/daemon/Dockerfile.
RUST_TOOLCHAIN: "1.97"
# Static client only; the daemon comes from the socket the runner mounts.
DOCKER_CLI_VERSION: "29.7.1"
jobs:
# --- Lint + TypeScript Check ---
@@ -94,6 +99,26 @@ jobs:
steps:
- uses: actions/checkout@v4
# The runner executes jobs inside a container that has no docker CLI,
# while act_runner mounts the host's socket at /var/run/docker.sock.
# Install just the client when it is missing, then prove the socket is
# actually reachable — the two failure modes look nothing alike and the
# message should say which one happened.
- name: Ensure docker CLI
run: |
if ! command -v docker >/dev/null 2>&1; then
url="https://download.docker.com/linux/static/stable/$(uname -m)/docker-${DOCKER_CLI_VERSION}.tgz"
if command -v curl >/dev/null 2>&1; then curl -fsSL "$url" -o /tmp/docker.tgz
else wget -qO /tmp/docker.tgz "$url"; fi
tar -xzf /tmp/docker.tgz -C /usr/local/bin --strip-components=1 docker/docker
fi
docker --version
docker version >/dev/null 2>&1 || {
echo "The docker socket is not reachable from this job."
echo "act_runner must mount it: leave container.docker_host empty in its config.yaml."
exit 1
}
- name: Build API image
run: docker build -f apps/api/Dockerfile -t gamepanel-api:ci .
@@ -124,6 +149,26 @@ jobs:
steps:
- uses: actions/checkout@v4
# The runner executes jobs inside a container that has no docker CLI,
# while act_runner mounts the host's socket at /var/run/docker.sock.
# Install just the client when it is missing, then prove the socket is
# actually reachable — the two failure modes look nothing alike and the
# message should say which one happened.
- name: Ensure docker CLI
run: |
if ! command -v docker >/dev/null 2>&1; then
url="https://download.docker.com/linux/static/stable/$(uname -m)/docker-${DOCKER_CLI_VERSION}.tgz"
if command -v curl >/dev/null 2>&1; then curl -fsSL "$url" -o /tmp/docker.tgz
else wget -qO /tmp/docker.tgz "$url"; fi
tar -xzf /tmp/docker.tgz -C /usr/local/bin --strip-components=1 docker/docker
fi
docker --version
docker version >/dev/null 2>&1 || {
echo "The docker socket is not reachable from this job."
echo "act_runner must mount it: leave container.docker_host empty in its config.yaml."
exit 1
}
- name: Registry login
run: |
printf '%s' "${{ secrets.REGISTRY_TOKEN }}" |
+36 -14
View File
@@ -7,12 +7,19 @@ FROM base AS deps
COPY pnpm-lock.yaml pnpm-workspace.yaml package.json ./
COPY apps/api/package.json apps/api/
COPY packages/database/package.json packages/database/
COPY packages/proto/package.json packages/proto/
COPY packages/shared/package.json packages/shared/
COPY packages/ui/package.json packages/ui/
RUN pnpm install --frozen-lockfile --prod=false
# pnpm creates no node_modules for a workspace package that has no
# dependencies of its own, and @source/shared has none. The COPY lines below
# name that path, so give them an empty directory to find instead of failing
# the build on a path pnpm never made.
RUN pnpm install --frozen-lockfile --prod=false && mkdir -p packages/shared/node_modules
# --- Build ---
FROM base AS build
# --- Type check ---
# Not an artifact producer: tsconfig.base.json sets noEmit, so this stage only
# proves the sources compile. The runtime stages below run TypeScript directly.
FROM base AS typecheck
COPY --from=deps /app/node_modules ./node_modules
COPY --from=deps /app/apps/api/node_modules ./apps/api/node_modules
COPY --from=deps /app/packages/database/node_modules ./packages/database/node_modules
@@ -40,25 +47,40 @@ WORKDIR /app/packages/database
CMD ["sh", "-c", "pnpm exec drizzle-kit push --force && pnpm exec tsx src/migrate.ts && pnpm exec tsx src/seed.ts"]
# --- Production ---
FROM node:20-alpine AS production
RUN corepack enable && corepack prepare pnpm@9.15.4 --activate
#
# Runs the TypeScript sources through tsx rather than a compiled bundle, the
# same way the migrate stage above already does.
#
# The workspace packages are consumed as TypeScript: every @source/* package
# points `main` at ./src/index.ts, which is what lets `pnpm dev` and Vite read
# them without a build step. A compiled entry point would resolve those bare
# imports to TypeScript files Node cannot load, and @source/proto derives the
# path of daemon.proto from its own module URL — a rule written for
# `/src/index.ts`. Following that decision here keeps one resolution model for
# development and production instead of two that disagree.
FROM base AS production
WORKDIR /app
ENV NODE_ENV=production
COPY --from=deps /app/node_modules ./node_modules
COPY --from=build /app/apps/api/dist ./apps/api/dist
COPY --from=build /app/apps/api/package.json ./apps/api/
COPY --from=build /app/packages/database/dist ./packages/database/dist
COPY --from=build /app/packages/database/package.json ./packages/database/
COPY --from=build /app/packages/shared/dist ./packages/shared/dist
COPY --from=build /app/packages/shared/package.json ./packages/shared/
COPY --from=deps /app/apps/api/node_modules ./apps/api/node_modules
COPY --from=deps /app/packages/database/node_modules ./packages/database/node_modules
COPY --from=deps /app/packages/shared/node_modules ./packages/shared/node_modules
COPY --from=deps /app/apps/api/node_modules ./apps/api/node_modules
COPY pnpm-workspace.yaml package.json ./
COPY apps/api ./apps/api
COPY packages/database ./packages/database
COPY packages/proto ./packages/proto
COPY packages/shared ./packages/shared
EXPOSE 3000
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s CMD wget -qO- http://localhost:3000/api/health || exit 1
# 127.0.0.1, not localhost: musl resolves localhost to ::1 first and the
# server binds IPv4, so the probe was refused on every run and the
# container never left the unhealthy state.
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s CMD wget -qO- http://127.0.0.1:3000/api/health || exit 1
CMD ["node", "apps/api/dist/index.js"]
# From the package directory, the same way `pnpm dev` runs it: pnpm's isolated
# node_modules puts tsx in apps/api/node_modules/.bin, not in the workspace
# root, so `pnpm exec` only finds it here.
WORKDIR /app/apps/api
CMD ["pnpm", "exec", "tsx", "src/index.ts"]
+1 -1
View File
@@ -1,4 +1,4 @@
FROM rust:1.83-bookworm AS build
FROM rust:1.97-bookworm AS build
# Install protoc
RUN apt-get update && apt-get install -y protobuf-compiler && rm -rf /var/lib/apt/lists/*
+8 -2
View File
@@ -8,7 +8,11 @@ COPY pnpm-lock.yaml pnpm-workspace.yaml package.json ./
COPY apps/web/package.json apps/web/
COPY packages/shared/package.json packages/shared/
COPY packages/ui/package.json packages/ui/
RUN pnpm install --frozen-lockfile --prod=false
# pnpm creates no node_modules for a workspace package that has no
# dependencies of its own, and @source/shared has none. The COPY lines below
# name that path, so give them an empty directory to find instead of failing
# the build on a path pnpm never made.
RUN pnpm install --frozen-lockfile --prod=false && mkdir -p packages/shared/node_modules
# --- Build ---
FROM base AS build
@@ -32,6 +36,8 @@ COPY apps/web/nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=build /app/apps/web/dist /usr/share/nginx/html
EXPOSE 80
HEALTHCHECK --interval=30s --timeout=5s CMD wget -qO- http://localhost/health || exit 1
# 127.0.0.1 for the same reason as the API image: localhost resolves to
# ::1, where nginx is not listening.
HEALTHCHECK --interval=30s --timeout=5s CMD wget -qO- http://127.0.0.1/health || exit 1
CMD ["nginx", "-g", "daemon off;"]
+5 -1
View File
@@ -32,7 +32,11 @@ services:
expose:
- "5432"
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${DB_USER:-gamepanel}"]
# -h forces a TCP probe. Without it pg_isready talks over the unix
# socket, which answers during the image's init phase while the
# server is not listening on 5432 yet — dependents then start and
# get ECONNREFUSED from a container Compose just called healthy.
test: ["CMD-SHELL", "pg_isready -h 127.0.0.1 -U ${DB_USER:-gamepanel}"]
interval: 10s
timeout: 5s
retries: 5
+5 -1
View File
@@ -23,7 +23,11 @@ services:
expose:
- "5432"
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${DB_USER:-gamepanel}"]
# -h forces a TCP probe. Without it pg_isready talks over the unix
# socket, which answers during the image's init phase while the
# server is not listening on 5432 yet — dependents then start and
# get ECONNREFUSED from a container Compose just called healthy.
test: ["CMD-SHELL", "pg_isready -h 127.0.0.1 -U ${DB_USER:-gamepanel}"]
interval: 10s
timeout: 5s
retries: 5