diff --git a/.gitea/workflows/publish-canvas-image.yml b/.gitea/workflows/publish-canvas-image.yml index b140dead0..5833d6657 100644 --- a/.gitea/workflows/publish-canvas-image.yml +++ b/.gitea/workflows/publish-canvas-image.yml @@ -208,6 +208,11 @@ jobs: build-args: | NEXT_PUBLIC_PLATFORM_URL=${{ steps.build_args.outputs.platform_url }} NEXT_PUBLIC_WS_URL=${{ steps.build_args.outputs.ws_url }} + # Bake the merge SHA into the image so /api/buildinfo reports the + # served canvas SHA (core#2235). Mirrors how the platform image + # surfaces GIT_SHA at /buildinfo. Full 40-char SHA (not the + # 7-char tag) so the fleet redeploy verification can match exactly. + BUILD_SHA=${{ github.sha }} # Ordered deploy (core#2226): the build job pushes the immutable # per-commit tag + the build-tracking staging-latest + the legacy # back-compat :sha- tag. It does NOT push :latest — :latest is diff --git a/canvas/Dockerfile b/canvas/Dockerfile index 3730ba3f8..975cb2bf8 100644 --- a/canvas/Dockerfile +++ b/canvas/Dockerfile @@ -24,6 +24,17 @@ COPY --from=builder /app/public ./public EXPOSE 3000 ENV PORT=3000 ENV HOSTNAME="0.0.0.0" +# Git SHA the image was built from, surfaced at /api/buildinfo so canvas +# deploys are verifiable by the served SHA the same way workspace-server's +# /buildinfo is (core#2235). Wired from `${{ github.sha }}` in +# publish-canvas-image.yml. Server-only (not NEXT_PUBLIC_) — the route +# handler reads it at runtime on the standalone Node server, so it stays +# out of the client bundle. Set on the final stage (not the builder) so it +# lives in the runtime env that force-dynamic reads per request. Default +# "dev" matches the route + workspace-server sentinel: an unwired build +# fails the SHA comparison closed instead of looking deployed. +ARG BUILD_SHA=dev +ENV BUILD_SHA=$BUILD_SHA # Non-root runtime — use addgroup/adduser without fixed GID/UID to avoid conflicts with base image RUN addgroup canvas 2>/dev/null || true && adduser -G canvas -s /bin/sh -D canvas 2>/dev/null || true USER canvas diff --git a/canvas/src/app/api/buildinfo/__tests__/route.test.ts b/canvas/src/app/api/buildinfo/__tests__/route.test.ts index ac2d8f7b0..7dcf014fc 100644 --- a/canvas/src/app/api/buildinfo/__tests__/route.test.ts +++ b/canvas/src/app/api/buildinfo/__tests__/route.test.ts @@ -1,12 +1,17 @@ /** * Canvas /api/buildinfo — version-display endpoint mirroring * workspace-server's /buildinfo. Lets `curl /api/buildinfo` - * confirm which git SHA is live on a canvas deployment. + * confirm which git SHA is live on a canvas deployment (core#2235). */ import { describe, it, expect, beforeEach, afterEach } from "vitest"; import { GET } from "../route"; -const ENV_KEYS = ["VERCEL_GIT_COMMIT_SHA", "VERCEL_GIT_COMMIT_REF", "VERCEL_ENV"]; +const ENV_KEYS = [ + "BUILD_SHA", + "VERCEL_GIT_COMMIT_SHA", + "VERCEL_GIT_COMMIT_REF", + "VERCEL_ENV", +]; describe("GET /api/buildinfo", () => { let saved: Record; @@ -23,13 +28,24 @@ describe("GET /api/buildinfo", () => { } }); - it("returns dev sentinel when Vercel env vars are unset", async () => { + it("returns dev sentinel when no SHA source is set", async () => { const res = await GET(); const body = await res.json(); expect(body).toEqual({ git_sha: "dev", git_ref: "", vercel_env: "local" }); }); - it("reports the SHA Vercel injected at build time", async () => { + it("reports BUILD_SHA baked into the Docker image (fleet deploy path)", async () => { + // BUILD_SHA is the authoritative source for the ECR-image fleet deploy, + // which never runs on Vercel. It must win even when a Vercel var is also + // present in the environment. + process.env.BUILD_SHA = "deadbeefcafe"; + process.env.VERCEL_GIT_COMMIT_SHA = "should-not-win"; + const res = await GET(); + const body = await res.json(); + expect(body.git_sha).toBe("deadbeefcafe"); + }); + + it("falls back to the SHA Vercel injected when BUILD_SHA is unset", async () => { process.env.VERCEL_GIT_COMMIT_SHA = "abc1234567890"; process.env.VERCEL_GIT_COMMIT_REF = "main"; process.env.VERCEL_ENV = "production"; diff --git a/canvas/src/app/api/buildinfo/route.ts b/canvas/src/app/api/buildinfo/route.ts index a8ff8aabe..33f280205 100644 --- a/canvas/src/app/api/buildinfo/route.ts +++ b/canvas/src/app/api/buildinfo/route.ts @@ -1,17 +1,36 @@ import { NextResponse } from "next/server"; // Mirror of workspace-server's GET /buildinfo (PR #2398). Lets a developer -// confirm which git SHA is live on a canvas deployment with the same -// `curl /buildinfo` flow they use against tenant workspaces. +// or the fleet redeploy workflow confirm which git SHA is live on a canvas +// deployment with the same `curl /api/buildinfo` flow used against +// tenant workspaces (core#2235; cross-ref core#2226). // -// Vercel injects VERCEL_GIT_COMMIT_SHA / _REF / VERCEL_ENV at build time -// from the deploying commit; outside Vercel (local `next dev`, harness) -// these are unset and the endpoint reports `git_sha: "dev"`. Same sentinel -// the workspace-server uses pre-ldflags-injection so both surfaces speak -// the same vocabulary. +// SHA source, in priority order: +// 1. BUILD_SHA — server-only env baked into the canvas Docker image at +// build time (Dockerfile `ARG BUILD_SHA` → `ENV BUILD_SHA`, wired +// from `${{ github.sha }}` in publish-canvas-image.yml). This is the +// authoritative source for the fleet's ECR-image deploy path, which +// does NOT run on Vercel. Read server-side here (App Router route +// handler runs on the standalone Node server, `output: "standalone"`), +// so it is intentionally NOT a NEXT_PUBLIC_ var — keeping it out of +// the client bundle. +// 2. VERCEL_GIT_COMMIT_SHA — Vercel injects this at build time when the +// canvas is deployed via Vercel rather than the Docker image. +// 3. "dev" — local `next dev` / test harness, where neither is set. Same +// sentinel workspace-server uses pre-ldflags-injection, so both +// surfaces speak the same vocabulary and an unconfigured deploy +// fails the SHA comparison closed instead of round-tripping "". +// +// force-dynamic so the response is evaluated at request time against the +// runtime env of the standalone server (where ENV BUILD_SHA lives), not +// frozen into a static asset at `next build`. +export const dynamic = "force-dynamic"; + export async function GET() { + const sha = + process.env.BUILD_SHA ?? process.env.VERCEL_GIT_COMMIT_SHA ?? "dev"; return NextResponse.json({ - git_sha: process.env.VERCEL_GIT_COMMIT_SHA ?? "dev", + git_sha: sha, git_ref: process.env.VERCEL_GIT_COMMIT_REF ?? "", vercel_env: process.env.VERCEL_ENV ?? "local", }); diff --git a/docker-compose.yml b/docker-compose.yml index 470fc9bb8..204c6beb7 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -188,6 +188,10 @@ services: NEXT_PUBLIC_PLATFORM_URL: ${NEXT_PUBLIC_PLATFORM_URL:-http://localhost:${PLATFORM_PUBLISH_PORT:-8080}} NEXT_PUBLIC_WS_URL: ${NEXT_PUBLIC_WS_URL:-ws://localhost:${PLATFORM_PUBLISH_PORT:-8080}/ws} NEXT_PUBLIC_ADMIN_TOKEN: ${ADMIN_TOKEN:-} + # SHA surfaced at /api/buildinfo (core#2235). CI passes the real merge + # SHA via the publish-canvas-image workflow build-args; local compose + # builds default to "dev" (the route's unwired sentinel). + BUILD_SHA: ${BUILD_SHA:-dev} depends_on: platform: condition: service_healthy