diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index fb10c9e268..cb46bcbf2a 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -155,11 +155,120 @@ jobs: tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} - # The cloud variant carries built bundled plugins for managed - # deployments (see the `cloud` stage in the Dockerfile). Published - # under the same tag set with a `-cloud` suffix (sha--cloud, - # latest-cloud, -cloud). Reuses the layer cache from the - # production build, so this mostly adds the plugin-build layers. + # The cloud variant carries built bundled plugins for managed deployments + # (see the `cloud` stage in the Dockerfile). It runs as its own job with no + # `needs:` on the stock publish above, so the two builds run in parallel and + # a failure or slow build in one never gates, delays, or skips the other. + # Both jobs share only the single top-level concurrency slot. Each job is a + # separate runner, so this one carries its own copy of the prep steps + # (checkout through schema labels) — the accepted cost of that isolation. + build-and-push-cloud: + runs-on: ubuntu-latest + timeout-minutes: 60 + steps: + - name: Checkout + uses: actions/checkout@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 + version="$(git describe --tags --match 'v*' --long --dirty 2>/dev/null || true)" + echo "version=${version}" >> "$GITHUB_OUTPUT" + echo "Stamping build version: ${version:-}" + + - name: Setup pnpm + uses: pnpm/action-setup@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@v7 + with: + node-version: 20 + + - name: Refresh lockfile for Docker build context + run: | + set -euo pipefail + pnpm install --lockfile-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 + + 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@v4 + with: + registry: ghcr.io + username: ${{ github.repository_owner }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@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 tag set with a `-cloud` suffix + # (sha--cloud, latest-cloud, -cloud). - name: Docker meta (cloud) id: meta-cloud uses: docker/metadata-action@v6 diff --git a/server/src/__tests__/cloud-image-bundled-plugins.test.ts b/server/src/__tests__/cloud-image-bundled-plugins.test.ts index 98955e0860..464fd73308 100644 --- a/server/src/__tests__/cloud-image-bundled-plugins.test.ts +++ b/server/src/__tests__/cloud-image-bundled-plugins.test.ts @@ -76,6 +76,36 @@ describe("cloud image bundled plugins", () => { expect(workflow).toMatch(/^\s*target: production$/m); }); + it("publishes the cloud image in its own job with no needs coupling", () => { + // The cloud publish runs as its own top-level job so the stock/production + // publish can never gate, delay, or skip it. Both jobs share only the + // single top-level concurrency slot; there is deliberately no `needs:` + // between them, so a failure in one is never coupled to the other. + const jobsSection = workflow.slice(workflow.indexOf("\njobs:\n")); + const headers = [...jobsSection.matchAll(/^ {2}([\w-]+):[^\n]*$/gm)]; + expect( + headers.length, + "docker.yml must declare at least two jobs under jobs:", + ).toBeGreaterThanOrEqual(2); + + // Locate the job block that carries the cloud build (target: cloud) and + // assert it declares no `needs:` — coupling it to another job would + // reintroduce the shared failure the split job exists to remove. + const cloudHeaderIdx = headers.findIndex((header, i) => { + const start = header.index ?? 0; + const end = headers[i + 1]?.index ?? jobsSection.length; + return jobsSection.slice(start, end).includes("target: cloud"); + }); + expect(cloudHeaderIdx, "one job must build the cloud target").toBeGreaterThanOrEqual(0); + const start = headers[cloudHeaderIdx].index ?? 0; + const end = headers[cloudHeaderIdx + 1]?.index ?? jobsSection.length; + const cloudJobBlock = jobsSection.slice(start, end); + expect( + cloudJobBlock, + "the cloud job must not couple to another job via needs:", + ).not.toMatch(/^\s*needs:/m); + }); + it("throttles the docker workflow with cancel-in-progress: false", () => { // Concurrency is declared at the workflow (top) level so a single group // spans the whole run, and cancel-in-progress is false so an in-flight