From f2262226383a531ba4c50e3274a99fa1a06bcedb Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Fri, 3 Jul 2026 15:49:23 +0200 Subject: [PATCH] Build Docker images from source (#1877) * fix(docker): build images from source Collapse the npm-install and source-build image paths into one Dockerfile so every Docker image is built from the checked-out workspace packages. Include procps in the runtime image because provider process cleanup depends on ps through tree-kill on Linux. * fix(docker): include native speech packages * fix(docker): cover source build inputs * fix(docker): ignore local agent artifacts --- .dockerignore | 20 +++++++ .github/workflows/docker.yml | 62 ++++++++------------- docker/base/Dockerfile | 36 ++++++++++-- docker/base/Dockerfile.source | 101 ---------------------------------- docs/docker.md | 19 +++---- docs/release.md | 5 +- 6 files changed, 84 insertions(+), 159 deletions(-) delete mode 100644 docker/base/Dockerfile.source diff --git a/.dockerignore b/.dockerignore index bbe22672d..0871ff68d 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,11 +1,31 @@ .git +.debug.conversations +.debug .dev .playwright-mcp +**/.playwright-mcp .paseo +**/.paseo-provider-history +.plans .tasks +.valknut +.claude/settings.local.json +**/.claude/settings.local.json +.claude/scheduled_tasks.lock +.claude/worktrees .wrangler **/.wrangler **/.tanstack +PLAN.md +valknut-report.html +valknut-report.json +.env* +**/.env* +.dev.vars +**/.dev.vars +*.pem +**/*.pem +**/.secrets **/node_modules **/dist **/build diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index 75968dca1..d7a5bc09c 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -5,7 +5,22 @@ on: branches: [main] paths: - "docker/**" + - ".dockerignore" - ".github/workflows/docker.yml" + - "package.json" + - "package-lock.json" + - "patches/**" + - "scripts/**" + - "tsconfig.json" + - "tsconfig.base.json" + - "packages/app/**" + - "packages/cli/**" + - "packages/client/**" + - "packages/expo-two-way-audio/**" + - "packages/highlight/**" + - "packages/protocol/**" + - "packages/relay/**" + - "packages/server/**" push: branches: [main] tags: @@ -13,7 +28,7 @@ on: workflow_dispatch: inputs: paseo_version: - description: "Paseo version to build. Required when publish is true." + description: "Expected source version to build. Required when publish is true." required: false default: "" publish: @@ -24,15 +39,6 @@ on: options: - "false" - "true" - source_build: - description: "Build from the checked-out source tree instead of npm." - required: false - default: "auto" - type: choice - options: - - auto - - "false" - - "true" publish_latest: description: "Also publish ghcr.io/getpaseo/paseo:latest. Ignored for prereleases." required: false @@ -57,7 +63,6 @@ jobs: image: ${{ steps.values.outputs.image }} install_version: ${{ steps.values.outputs.install_version }} publish: ${{ steps.values.outputs.publish }} - source_build: ${{ steps.values.outputs.source_build }} check_tag: ${{ steps.values.outputs.check_tag }} publish_tags: ${{ steps.values.outputs.publish_tags }} steps: @@ -68,7 +73,6 @@ jobs: INPUT_PASEO_VERSION: ${{ inputs.paseo_version }} INPUT_PUBLISH: ${{ inputs.publish }} INPUT_PUBLISH_LATEST: ${{ inputs.publish_latest }} - INPUT_SOURCE_BUILD: ${{ inputs.source_build }} REPO_OWNER: ${{ github.repository_owner }} REF_NAME: ${{ github.ref_name }} run: | @@ -76,9 +80,9 @@ jobs: owner="$(printf '%s' "${REPO_OWNER}" | tr '[:upper:]' '[:lower:]')" image="ghcr.io/${owner}/paseo" - install_version="${INPUT_PASEO_VERSION:-latest}" + package_version="$(node -p "require('./package.json').version")" + install_version="${INPUT_PASEO_VERSION:-${package_version}}" publish=false - source_build=false publish_latest=false prerelease=false @@ -87,7 +91,6 @@ jobs: publish=true if [[ "${REF_NAME}" == *-* ]]; then prerelease=true - source_build=true else publish_latest=true fi @@ -104,24 +107,6 @@ jobs: prerelease=true fi - case "${INPUT_SOURCE_BUILD:-auto}" in - true) - source_build=true - ;; - false) - source_build=false - ;; - auto) - if [[ "${prerelease}" == "true" ]]; then - source_build=true - fi - ;; - *) - echo "::error::source_build must be auto, true, or false." - exit 1 - ;; - esac - if [[ "${INPUT_PUBLISH_LATEST:-false}" == "true" && "${prerelease}" != "true" ]]; then publish_latest=true fi @@ -137,14 +122,13 @@ jobs: echo "image=${image}" echo "install_version=${install_version}" echo "publish=${publish}" - echo "source_build=${source_build}" echo "check_tag=${check_tag}" echo "publish_tags<> "$GITHUB_OUTPUT" - echo "Resolved image=${image} install_version=${install_version} publish=${publish} source_build=${source_build}" + echo "Resolved image=${image} install_version=${install_version} publish=${publish}" build: needs: setup @@ -160,8 +144,8 @@ jobs: - uses: docker/build-push-action@v7 with: - context: ${{ needs.setup.outputs.source_build == 'true' && '.' || 'docker/base' }} - file: ${{ needs.setup.outputs.source_build == 'true' && 'docker/base/Dockerfile.source' || 'docker/base/Dockerfile' }} + context: . + file: docker/base/Dockerfile platforms: ${{ env.PLATFORMS }} build-args: | PASEO_VERSION=${{ needs.setup.outputs.install_version }} @@ -193,8 +177,8 @@ jobs: - uses: docker/build-push-action@v7 with: - context: ${{ needs.setup.outputs.source_build == 'true' && '.' || 'docker/base' }} - file: ${{ needs.setup.outputs.source_build == 'true' && 'docker/base/Dockerfile.source' || 'docker/base/Dockerfile' }} + context: . + file: docker/base/Dockerfile platforms: ${{ env.PLATFORMS }} build-args: | PASEO_VERSION=${{ needs.setup.outputs.install_version }} diff --git a/docker/base/Dockerfile b/docker/base/Dockerfile index 904372610..562b898ca 100644 --- a/docker/base/Dockerfile +++ b/docker/base/Dockerfile @@ -1,9 +1,32 @@ # syntax=docker/dockerfile:1 ARG NODE_IMAGE=node:22-bookworm-slim -FROM ${NODE_IMAGE} +FROM --platform=$BUILDPLATFORM ${NODE_IMAGE} AS source-pack -ARG PASEO_VERSION=latest +ARG PASEO_VERSION + +ENV ONNXRUNTIME_NODE_INSTALL=skip + +WORKDIR /tmp/paseo-src +COPY . . + +RUN set -eux; \ + if [ -n "${PASEO_VERSION:-}" ]; then \ + test "$(node -p "require('./package.json').version")" = "${PASEO_VERSION}"; \ + fi; \ + node -e 'const fs=require("node:fs"); const pkg=JSON.parse(fs.readFileSync("package.json","utf8")); delete pkg.scripts.prepare; fs.writeFileSync("package.json", `${JSON.stringify(pkg)}\n`);'; \ + npm ci + +RUN set -eux; \ + mkdir -p /tmp/paseo-packs; \ + npm pack --workspace=@getpaseo/highlight --pack-destination /tmp/paseo-packs; \ + npm pack --workspace=@getpaseo/relay --pack-destination /tmp/paseo-packs; \ + npm pack --workspace=@getpaseo/protocol --pack-destination /tmp/paseo-packs; \ + npm pack --workspace=@getpaseo/client --pack-destination /tmp/paseo-packs; \ + npm pack --workspace=@getpaseo/server --pack-destination /tmp/paseo-packs; \ + npm pack --workspace=@getpaseo/cli --pack-destination /tmp/paseo-packs + +FROM ${NODE_IMAGE} ENV HOME=/home/paseo \ PASEO_HOME=/home/paseo/.paseo \ @@ -29,13 +52,14 @@ RUN set -eux; \ gosu \ lbzip2 \ openssh-client \ + procps \ tini; \ rm -rf /var/lib/apt/lists/* +COPY --from=source-pack /tmp/paseo-packs /tmp/paseo-packs RUN set -eux; \ - npm install -g --omit=optional \ - "@getpaseo/server@${PASEO_VERSION}" \ - "@getpaseo/cli@${PASEO_VERSION}"; \ + npm install -g /tmp/paseo-packs/*.tgz; \ + rm -rf /tmp/paseo-packs; \ npm cache clean --force; \ server_entry="$(npm root -g)/@getpaseo/server/dist/scripts/supervisor-entrypoint.js"; \ test -f "$server_entry"; \ @@ -66,7 +90,7 @@ RUN set -eux; \ "$XDG_CACHE_HOME"; \ chown -R paseo:paseo /home/paseo /workspace -COPY rootfs/ / +COPY docker/base/rootfs/ / RUN chmod +x /usr/local/bin/paseo-docker-entrypoint WORKDIR /workspace diff --git a/docker/base/Dockerfile.source b/docker/base/Dockerfile.source deleted file mode 100644 index 0c93f08a0..000000000 --- a/docker/base/Dockerfile.source +++ /dev/null @@ -1,101 +0,0 @@ -# syntax=docker/dockerfile:1 - -ARG NODE_IMAGE=node:22-bookworm-slim -FROM --platform=$BUILDPLATFORM ${NODE_IMAGE} AS source-pack - -ARG PASEO_VERSION - -ENV ONNXRUNTIME_NODE_INSTALL=skip - -WORKDIR /tmp/paseo-src -COPY . . - -RUN set -eux; \ - test "$(node -p "require('./package.json').version")" = "${PASEO_VERSION}"; \ - node -e 'const fs=require("node:fs"); const pkg=JSON.parse(fs.readFileSync("package.json","utf8")); delete pkg.scripts.prepare; fs.writeFileSync("package.json", `${JSON.stringify(pkg)}\n`);'; \ - npm ci - -RUN set -eux; \ - mkdir -p /tmp/paseo-packs; \ - npm pack --workspace=@getpaseo/highlight --pack-destination /tmp/paseo-packs; \ - npm pack --workspace=@getpaseo/relay --pack-destination /tmp/paseo-packs; \ - npm pack --workspace=@getpaseo/protocol --pack-destination /tmp/paseo-packs; \ - npm pack --workspace=@getpaseo/client --pack-destination /tmp/paseo-packs; \ - npm pack --workspace=@getpaseo/server --pack-destination /tmp/paseo-packs; \ - npm pack --workspace=@getpaseo/cli --pack-destination /tmp/paseo-packs - -FROM ${NODE_IMAGE} - -ENV HOME=/home/paseo \ - PASEO_HOME=/home/paseo/.paseo \ - PASEO_LISTEN=0.0.0.0:6767 \ - PASEO_WEB_UI_ENABLED=true \ - PASEO_LOG_FORMAT=json \ - PASEO_LOG_LEVEL=info \ - CLAUDE_CONFIG_DIR=/home/paseo/.claude \ - CODEX_HOME=/home/paseo/.codex \ - XDG_CONFIG_HOME=/home/paseo/.config \ - XDG_DATA_HOME=/home/paseo/.local/share \ - XDG_STATE_HOME=/home/paseo/.local/state \ - XDG_CACHE_HOME=/home/paseo/.cache \ - ONNXRUNTIME_NODE_INSTALL=skip - -RUN set -eux; \ - apt-get update; \ - apt-get install -y --no-install-recommends \ - bash \ - ca-certificates \ - curl \ - git \ - gosu \ - lbzip2 \ - openssh-client \ - tini; \ - rm -rf /var/lib/apt/lists/* - -COPY --from=source-pack /tmp/paseo-packs /tmp/paseo-packs -RUN set -eux; \ - npm install -g --omit=optional /tmp/paseo-packs/*.tgz; \ - rm -rf /tmp/paseo-packs; \ - npm cache clean --force; \ - server_entry="$(npm root -g)/@getpaseo/server/dist/scripts/supervisor-entrypoint.js"; \ - test -f "$server_entry"; \ - printf '%s\n' "$server_entry" > /etc/paseo-server-entry; \ - node --check "$server_entry" - -RUN set -eux; \ - existing_group="$(getent group 1000 | cut -d: -f1 || true)"; \ - if [ -n "$existing_group" ] && [ "$existing_group" != "paseo" ]; then \ - groupmod --new-name paseo "$existing_group"; \ - elif [ -z "$existing_group" ]; then \ - groupadd --gid 1000 paseo; \ - fi; \ - existing_user="$(getent passwd 1000 | cut -d: -f1 || true)"; \ - if [ -n "$existing_user" ] && [ "$existing_user" != "paseo" ]; then \ - usermod --login paseo --gid paseo --home /home/paseo --shell /bin/bash "$existing_user"; \ - elif [ -z "$existing_user" ]; then \ - useradd --uid 1000 --gid paseo --create-home --home-dir /home/paseo --shell /bin/bash paseo; \ - fi; \ - mkdir -p \ - /workspace \ - "$PASEO_HOME" \ - "$CLAUDE_CONFIG_DIR" \ - "$CODEX_HOME" \ - "$XDG_CONFIG_HOME" \ - "$XDG_DATA_HOME" \ - "$XDG_STATE_HOME" \ - "$XDG_CACHE_HOME"; \ - chown -R paseo:paseo /home/paseo /workspace - -COPY docker/base/rootfs/ / -RUN chmod +x /usr/local/bin/paseo-docker-entrypoint - -WORKDIR /workspace - -EXPOSE 6767 -VOLUME ["/home/paseo"] - -HEALTHCHECK --interval=30s --timeout=5s --start-period=30s --retries=3 \ - CMD node -e "const listen=process.env.PASEO_LISTEN||'0.0.0.0:6767'; const m=listen.match(/:(\\d+)$/); const port=m?Number(m[1]):6767; require('http').get({hostname:'127.0.0.1',port,path:'/api/health'},r=>process.exit(r.statusCode===200?0:1)).on('error',()=>process.exit(1))" - -ENTRYPOINT ["/usr/bin/tini", "--", "/usr/local/bin/paseo-docker-entrypoint"] diff --git a/docs/docker.md b/docs/docker.md index 160a2e9fd..268331303 100644 --- a/docs/docker.md +++ b/docs/docker.md @@ -10,8 +10,7 @@ The image source lives in [`docker/`](../docker/). The official image: -- installs `@getpaseo/server` and `@getpaseo/cli` from npm for stable images, - or from source-built workspace tarballs for beta images +- builds `@getpaseo/server` and `@getpaseo/cli` from source-built workspace tarballs - runs the daemon as the non-root `paseo` user - listens on `0.0.0.0:6767` inside the container - enables the bundled daemon web UI with `PASEO_WEB_UI_ENABLED=true` @@ -190,16 +189,17 @@ See [SECURITY.md](../SECURITY.md) for the daemon trust model. ## Building Locally ```bash -docker build -t paseo:local docker/base +docker build -f docker/base/Dockerfile -t paseo:local . ``` -To bake a specific published npm version: +To assert the source tree version while building: ```bash docker build \ --build-arg PASEO_VERSION=0.1.102 \ -t paseo:0.1.102 \ - docker/base + -f docker/base/Dockerfile \ + . ``` The Docker workflow builds the image on pull requests and on `main` as a @@ -216,13 +216,12 @@ pushing a `v*` release tag: gh workflow run docker.yml \ --ref main \ -f paseo_version=0.1.102-beta.1 \ - -f publish=true \ - -f source_build=auto + -f publish=true ``` -Manual Docker publishes require an explicit `paseo_version`. Prerelease -versions build from the checked-out source tree by default and publish only the -exact prerelease image tag. +Manual Docker publishes require an explicit `paseo_version`. The workflow builds +from the checked-out source tree and publishes only the exact prerelease image +tag for prerelease versions. The published image is multi-arch for `linux/amd64` and `linux/arm64`. diff --git a/docs/release.md b/docs/release.md index f91f0d837..7ca078a20 100644 --- a/docs/release.md +++ b/docs/release.md @@ -50,7 +50,7 @@ npm run release:patch This bumps the version across all workspaces, runs checks, publishes to npm, and pushes the branch + tag. The tag push triggers `Desktop Release`, `Android APK Release`, `Docker`, and `Release Notes Sync` on GitHub Actions. EAS picks up the same tag via the EAS GitHub app and starts the iOS + Android store builds in parallel (see "Mobile builds (EAS)" below) — there is no `release-mobile.yml` in this repo. -The Docker workflow builds images on pull requests and on `main` as non-publishing checks. Stable `vX.Y.Z` tag pushes publish `ghcr.io/getpaseo/paseo:X.Y.Z` and `ghcr.io/getpaseo/paseo:latest`; beta `vX.Y.Z-beta.N` tag pushes publish only `ghcr.io/getpaseo/paseo:X.Y.Z-beta.N` and never move `latest`. Beta Docker images build from the checked-out source tree so the beta flow can intentionally skip npm publishing. +The Docker workflow builds images from the checked-out source tree on pull requests and on `main` as non-publishing checks. Stable `vX.Y.Z` tag pushes publish `ghcr.io/getpaseo/paseo:X.Y.Z` and `ghcr.io/getpaseo/paseo:latest`; beta `vX.Y.Z-beta.N` tag pushes publish only `ghcr.io/getpaseo/paseo:X.Y.Z-beta.N` and never move `latest`. **Releases are always patch.** "Release paseo", "release stable", "ship stable", and similar always mean a patch bump from the previous stable. Never bump minor or major to trigger a build, ever — minor and major bumps are reserved for genuinely larger product cuts and require an explicit user instruction with the word "minor" or "major". If you find yourself reaching for `release:minor` to retrigger a failed build, you are doing the wrong thing — push a retry tag instead (see "Fixing a failed release build" below). @@ -281,8 +281,7 @@ and EAS mobile release builds. Use the Docker workflow dispatch instead: gh workflow run docker.yml \ --ref main \ -f paseo_version=X.Y.Z-beta.N \ - -f publish=true \ - -f source_build=auto + -f publish=true ``` This replaces `ghcr.io/getpaseo/paseo:X.Y.Z-beta.N` in place without touching