mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-06 10:48:12 +02:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - Paperclip Cloud deploys images built from master commits. > - Cloud image builds share GitHub-hosted capacity with other workflows. > - The organization already operates AWS runners through RunsOn Fleet. > - This pull request allows approved master builds to use a dedicated cloud Fleet. > - The benefit is separate build capacity with a quick operator rollback. ## Linked Issues or Issue Description Refs #13189, #13192. **What existing behavior does this improve?** Placement of the Docker cloud build after a master merge. **Current behavior** Every Docker cloud build uses a GitHub-hosted runner. Busy periods delay the job. **Proposed behavior** An operator variable enables the approved cloud Fleet for canonical master pushes and manual master builds. Other events, refs, and repositories use GitHub-hosted runners. ## What Changed - Add a guarded AWS runner selector to the Docker cloud job. - Keep the existing image cache, verification, and publication steps. - Test the selector against master, branch, tag, PR, fork, and disabled contexts. - Document provisioning requirements, placement checks, and rollback. ## Verification - 29 focused Node tests pass for routing, readiness, and disk handling. - The full workflow-script Node suite passes. - `pnpm -r typecheck` passes locally. - The pinned PR routing regression suite passes. The first live PR run assigned 21 jobs to the approved AWS PR group. AWS then reclaimed 16 Spot instances. The failed run is being repeated on GitHub-hosted runners while the Fleet moves to On-Demand. - Actionlint passes with existing shellcheck findings excluded (SC2012, SC2016, SC2129). - `git diff --check` passes. - Greptile reports 5/5 on commit `764d505a41dd2023751c3f361906fa9ea35bf0c6`, with no review threads. - All 30 current-head CI checks pass, including typecheck, build, all server/workspace test shards, Runner verification, and browser tests. Two Storybook checks are intentionally skipped for this change. Run: https://github.com/paperclipai/paperclip/actions/runs/34630550799 - The broader local test/build sequence is still running. This Mac has reported failures in unchanged application suites; their complete Linux CI shards pass. Local targeted workflow tests and typecheck pass. - Both On-Demand Fleets are deployed and healthy. Live master cloud-build verification follows the merge. ## Risks - Missing Fleet capacity or runner-group authorization can leave an AWS job queued. Disable `AWS_CLOUD_BUILDS_ENABLED` and rerun the workflow to use GitHub-hosted capacity. - The runner group must restrict access to this repository and the master version of `docker-cloud.yml`. - Docker needs more disk space than the PR Fleet. Provision 120 GiB disks and retain the free-space check. - This changes image build placement only. Source verification and migrator publication remain separate prerequisites. ## Model Used OpenAI GPT-6 through Codex, with reasoning, tool use, and code execution. The exact serving model identifier and context-window size are not exposed by this environment. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
301 lines
14 KiB
YAML
301 lines
14 KiB
YAML
name: Docker cloud
|
|
|
|
on:
|
|
workflow_dispatch:
|
|
workflow_call:
|
|
|
|
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:-<none>}"
|
|
|
|
# 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"
|
|
|
|
# Each SHA exports its own cache. Import recent first-parent caches so
|
|
# a late older build cannot overwrite a newer build's cache manifest.
|
|
# The legacy ref keeps the first builds warm during the transition.
|
|
- name: Select cloud cache ancestry
|
|
id: cloud-cache
|
|
env:
|
|
CACHE_IMAGE: ghcr.io/${{ github.repository }}
|
|
run: |
|
|
set -euo pipefail
|
|
{
|
|
echo 'sources<<CACHE_SOURCES'
|
|
for commit in $(git rev-list --first-parent --max-count=10 HEAD); do
|
|
echo "type=registry,ref=$CACHE_IMAGE:buildcache-cloud-$commit"
|
|
done
|
|
echo "type=registry,ref=$CACHE_IMAGE:buildcache-cloud"
|
|
echo 'CACHE_SOURCES'
|
|
} >> "$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
|
|
run: |
|
|
set -euo pipefail
|
|
pnpm install --resolution-only --ignore-scripts --no-frozen-lockfile
|
|
|
|
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
|
|
|
|
# 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, <version>-cloud,
|
|
# sha-<short>-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.sources }}
|
|
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"
|