name: Docker cloud on: workflow_dispatch: workflow_call: inputs: staging_artifact_base_url: type: string default: "" staging_lock_sha256: type: string default: "" permissions: {} # Independent SHAs can build immediately on separate runners. # Repeated requests for the same source serialize without cancelling a build. # No mutable canary channel is promoted here; docker.yml owns that operation. concurrency: group: docker-cloud-${{ github.sha }} cancel-in-progress: false jobs: build-and-push-cloud: # Only canonical master builds can consume the release Fleet. The runner # group must also allow this workflow only at refs/heads/master. # Keep an operator switch for a full-run retry on GitHub-hosted runners. runs-on: ${{ vars.AWS_CLOUD_BUILDS_ENABLED == 'true' && github.repository == 'paperclipai/paperclip' && github.repository_id == '1170821064' && github.ref == 'refs/heads/master' && (github.event_name == 'push' || github.event_name == 'workflow_dispatch') && 'runs-on/fleet=paperclip-cloud-build-x64/env=public-ci' || 'ubuntu-latest' }} # Fleet instances expire after 45 minutes, including bootstrap and cleanup. timeout-minutes: ${{ vars.AWS_CLOUD_BUILDS_ENABLED == 'true' && github.repository == 'paperclipai/paperclip' && github.repository_id == '1170821064' && github.ref == 'refs/heads/master' && (github.event_name == 'push' || github.event_name == 'workflow_dispatch') && 40 || 60 }} permissions: contents: read packages: write steps: - name: Checkout uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 with: # Full history and tags so `git describe` below can compute the # release version to stamp into the image. fetch-depth: 0 # `.git` is dockerignored, so a running image cannot derive its own # version and otherwise reports the source package.json placeholder in # analytics and the debug panel. Compute it here from the pristine # checkout (real CalVer drift from the nearest release tag) and pass it # into the build. Empty when no release tag is reachable — the server # then keeps its existing fallbacks. - name: Compute build version id: build-version run: | set -euo pipefail case "${GITHUB_REF}" in refs/tags/nightly/v*) # Lane tags carry the exact published version; stamp it verbatim # instead of describing drift from the nearest stable tag. version="${GITHUB_REF#refs/tags/nightly/v}" ;; refs/tags/beta/v*) version="${GITHUB_REF#refs/tags/beta/v}" ;; *) version="$(git describe --tags --match 'v*' --long --dirty 2>/dev/null || true)" ;; esac echo "version=${version}" >> "$GITHUB_OUTPUT" echo "Stamping build version: ${version:-}" # ISO week stamp for the Dockerfile's tool layer: the layer caches # across commits and re-pulls the @latest CLI tools when the week rolls # over, instead of on every build. - name: Compute tool cache epoch id: tools-epoch run: echo "epoch=$(date -u +%G-W%V)" >> "$GITHUB_OUTPUT" - name: Setup pnpm uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6 with: version: 9.15.4 run_install: false # No dependency cache here: this workflow publishes release images, and # restoring a shared Actions cache into the build inputs would let a # poisoned cache entry reach the published artifact. - name: Setup Node.js uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7 with: node-version: 24 - name: Refresh lockfile for Docker build context env: STAGING_ARTIFACT_BASE_URL: ${{ inputs.staging_artifact_base_url }} EXPECTED_LOCK_SHA256: ${{ inputs.staging_lock_sha256 }} run: | set -euo pipefail pnpm install --resolution-only --ignore-scripts --no-frozen-lockfile if [ -n "$STAGING_ARTIFACT_BASE_URL" ]; then [[ "$EXPECTED_LOCK_SHA256" =~ ^[a-f0-9]{64}$ ]] echo "$EXPECTED_LOCK_SHA256 pnpm-lock.yaml" | sha256sum --check --strict fi changed="$(git status --porcelain)" if [ -z "$changed" ]; then echo "Lockfile already matches package metadata." exit 0 fi if printf '%s\n' "$changed" | grep -Fvq ' pnpm-lock.yaml'; then echo "Unexpected files changed during lockfile refresh:" echo "$changed" exit 1 fi echo "Using refreshed pnpm-lock.yaml in the Docker build context." - name: Free runner disk run: | set -euo pipefail echo "Disk before cleanup:" df -h # A measured hosted cloud build started with 86 GB available. # Keep ample headroom for BuildKit and image verification, but # avoid minutes deleting SDKs when neither filesystem needs space. minimum_free_kib=$((64 * 1024 * 1024)) if docker_root="$(docker info --format '{{.DockerRootDir}}')" \ && available_kib="$(df -Pk "$docker_root" "$GITHUB_WORKSPACE" | awk 'NR > 1 { rows++; if ($4 !~ /^[0-9]+$/) invalid = 1; if (min == "" || $4 < min) min = $4 } END { if (invalid || rows != 2) exit 1; print min }')" \ && [[ "$available_kib" =~ ^[0-9]+$ ]] \ && (( available_kib >= minimum_free_kib )); then echo "At least 64 GiB is available for Docker and the workspace; skipping cleanup." exit 0 fi pnpm store prune || true sudo apt-get clean || true sudo rm -rf \ /usr/share/dotnet \ /usr/share/swift \ /usr/local/lib/android \ /usr/local/share/boost \ /usr/local/share/powershell \ /opt/ghc \ /opt/hostedtoolcache/CodeQL \ /opt/hostedtoolcache/PyPy \ /opt/hostedtoolcache/Ruby || true docker system prune -af || true echo "Disk after cleanup:" df -h - name: Login to GitHub Container Registry uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4 with: registry: ghcr.io username: ${{ github.repository_owner }} password: ${{ secrets.GITHUB_TOKEN }} - name: Set up Docker Buildx uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4 # Mixing several historical manifests missed otherwise reusable native # layers on fresh builders. Import the nearest available complete cache. - name: Select cloud cache ancestry id: cloud-cache env: CACHE_IMAGE: ghcr.io/${{ github.repository }} run: node scripts/select-cloud-cache.mjs # Deployment tooling reads these labels from the registry to verify an # image's schema expectations against a migrator before deploying it, # without pulling the image. The server refuses to start when the # database is missing bundled migrations, so orchestrators need a cheap # way to check image/migrator compatibility up front. - name: Compute schema migration labels id: schema run: | set -euo pipefail last=$(ls packages/db/src/migrations/*.sql | sed 's|.*/||' | LC_ALL=C sort | tail -1) count=$(ls packages/db/src/migrations/*.sql | wc -l | tr -d ' ') echo "last=${last}" >> "$GITHUB_OUTPUT" echo "count=${count}" >> "$GITHUB_OUTPUT" # Published under the same lane tag set as the self-hosted image, with a # `-cloud` suffix (nightly-cloud, latest-cloud, -cloud, # sha--cloud). `:canary-cloud` follows the same retag-step # ownership rule as `:canary` above. - name: Docker meta (cloud) id: meta-cloud uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6 with: images: ghcr.io/${{ github.repository }} flavor: | suffix=-cloud,onlatest=true tags: | type=raw,value=nightly,enable=${{ startsWith(github.ref, 'refs/tags/nightly/v') }} type=raw,value=beta,enable=${{ startsWith(github.ref, 'refs/tags/beta/v') }} type=raw,value=latest,enable=${{ startsWith(github.ref, 'refs/tags/v') }} type=semver,pattern={{version}},enable=${{ startsWith(github.ref, 'refs/tags/v') }} type=semver,pattern={{major}}.{{minor}},enable=${{ startsWith(github.ref, 'refs/tags/v') }} type=sha labels: | io.github.paperclipai.schema.last-migration=${{ steps.schema.outputs.last }} io.github.paperclipai.schema.migration-count=${{ steps.schema.outputs.count }} - name: Build and push (cloud) id: build-cloud uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7 with: context: . target: cloud # Space-separated sandbox-provider directory names to build into # the variant; add here when managed deployments need another. # CLOUD_BUNDLED_SERVER_DEPS names the optional peer packages the # variant installs from server/package.json's declared version; # add another name there when a managed tenant needs it. build-args: | USER_UID=1001 USER_GID=1001 CLOUD_BUNDLED_PLUGINS=daytona CLOUD_BUNDLED_SERVER_DEPS=@sentry/node PAPERCLIP_BUILD_VERSION=${{ steps.build-version.outputs.version }} PAPERCLIP_BUILD_COMMIT=${{ github.sha }} CLI_TOOLS_CACHE_EPOCH=${{ steps.tools-epoch.outputs.epoch }} # amd64 only, unlike the self-hosted image above: the cloud variant # is consumed exclusively by managed-deployment hosts, which run # amd64. The QEMU-emulated arm64 half dominated this job's wall # clock, and dropping it roughly halves time-to-deployable-image. platforms: linux/amd64 push: true # Same-SHA builds serialize above; different SHAs never share a # writable cache ref. Registry layers are content-addressed and # shared even when cache manifests have separate tags. cache-from: ${{ steps.cloud-cache.outputs.source }} cache-to: type=registry,ref=ghcr.io/${{ github.repository }}:buildcache-cloud-${{ github.sha }},mode=max tags: ${{ steps.meta-cloud.outputs.tags }} labels: ${{ steps.meta-cloud.outputs.labels }} # The cloud target installs @sentry/node at the version # server/package.json declares, into a directory the server's own # module resolution walks. Verify the image this job just pushed, not # a local build, so a build-cache or layer-ordering regression is # caught before any tenant runs the image. - name: Verify the pushed image resolves the declared Sentry version env: IMAGE: ghcr.io/${{ github.repository }}@${{ steps.build-cloud.outputs.digest }} run: | set -euo pipefail expected="$(node -e "process.stdout.write(require('./server/package.json').peerDependencies['@sentry/node'])")" test -n "$expected" installed="$(docker run --rm --pull always \ -v "$PWD/scripts/assert-cloud-image-sentry.mjs:/app/server/.ci-sentry-probe.mjs:ro" \ --entrypoint node "$IMAGE" /app/server/.ci-sentry-probe.mjs)" echo "Declared optional peer version: $expected" echo "Installed in the pushed image: $installed" if [ "$installed" != "$expected" ]; then echo "ERROR: the pushed image resolves @sentry/node@$installed, expected @sentry/node@$expected" >&2 exit 1 fi echo "The pushed image resolves the declared @sentry/node version." # Managed hosts run node as 1001:1001. Bake that identity into the image # so usermod does not walk the mounted home on every container start. # Check before the entrypoint can repair a wrongly built identity. - name: Verify cloud runtime user env: IMAGE: ghcr.io/${{ github.repository }}@${{ steps.build-cloud.outputs.digest }} run: | set -euo pipefail docker run --rm --entrypoint sh "$IMAGE" -ec ' test "$(id -u node)" = 1001 test "$(id -g node)" = 1001 test "$USER_UID" = 1001 test "$USER_GID" = 1001 ' docker run --rm -e USER_UID=1001 -e USER_GID=1001 "$IMAGE" sh -ec ' test "$(id -u)" = 1001 test "$(id -g)" = 1001 test -w "$PAPERCLIP_HOME" ' # Verify the independently published cloud image without waiting for # the self-hosted manifest job. The Sentry check already pulled it. - name: Verify cloud PID 1 reaps orphaned processes env: IMAGE: ghcr.io/${{ github.repository }}@${{ steps.build-cloud.outputs.digest }} run: docker run --rm -i "$IMAGE" sh -s < scripts/assert-orphan-reaping.sh # Cloud's commit resolver and preview-artifact planner use the full SHA. # Publish that address only after checking this build's exact digest. # Retagging reuses the registry manifest and does not rebuild the image. - name: Publish verified full-SHA cloud tag env: IMAGE: ghcr.io/${{ github.repository }}@${{ steps.build-cloud.outputs.digest }} FULL_SHA_TAG: ghcr.io/${{ github.repository }}:sha-${{ github.sha }}-cloud run: | set -euo pipefail revision="$(docker image inspect "$IMAGE" --format '{{ index .Config.Labels "org.opencontainers.image.revision" }}')" platform="$(docker image inspect "$IMAGE" --format '{{ .Os }}/{{ .Architecture }}')" test "$revision" = "$GITHUB_SHA" test "$platform" = linux/amd64 docker buildx imagetools create --prefer-index=false --tag "$FULL_SHA_TAG" "$IMAGE"